Compare commits

..

27 Commits

Author SHA1 Message Date
ebfeef9e01 Merge pull request 'Fixed FileMaker and FileReviewer access to COMPLETED files' (#283) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#283
2026-09-01 21:58:43 +03:30
cf74d75146 Fixed FileMaker and FileReviewer access to COMPLETED files 2026-09-01 21:55:38 +03:30
f5a60eafb1 Merge pull request 'feat: complete in-person claims without final sign' (#282) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#282
2026-09-01 15:04:51 +03:30
SepehrYahyaee
ae49031c55 feat: complete in-person claims without final sign 2026-09-01 15:03:08 +03:30
23d636416d Merge pull request 'YARA-1259' (#281) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#281
2026-09-01 14:18:10 +03:30
SepehrYahyaee
6880de5960 YARA-1259 2026-09-01 14:13:52 +03:30
85bd892720 Merge pull request 'Fix PDF return data' (#280) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#280
2026-08-31 12:32:00 +03:30
SepehrYahyaee
a33466025d Fix PDF return data 2026-08-31 12:29:31 +03:30
a5d2f5a2b9 Merge pull request 'YARA-1241' (#279) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#279
2026-08-26 15:58:03 +03:30
SepehrYahyaee
226b63aefb Added new data for YARA-1241 2026-08-26 15:57:09 +03:30
5203c07154 merge upstream 2026-08-26 09:42:44 +03:30
SepehrYahyaee
6566b5f112 YARA-1246 2026-08-25 16:50:06 +03:30
24a38a6780 Merge pull request 'Added data for parsian to seed' (#278) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#278
2026-08-25 12:28:05 +03:30
SepehrYahyaee
dc3748ae94 Added data for parsian to seed 2026-08-25 12:27:21 +03:30
fe83c20e7e Merge pull request 'Added data seed script' (#277) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#277
2026-08-25 10:12:03 +03:30
SepehrYahyaee
e5a28238e5 Added data seed script 2026-08-25 10:11:19 +03:30
8cf7b7b229 Merge pull request 'YARA-1241' (#276) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#276
2026-08-24 16:51:16 +03:30
SepehrYahyaee
e4ef95a23e YARA-1241 2026-08-24 16:50:39 +03:30
5ec40f66dc Merge pull request 'YARA-985, YARA-1244' (#275) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#275
2026-08-24 16:34:27 +03:30
SepehrYahyaee
f6f4429ce7 YARA-985 2026-08-24 16:33:18 +03:30
SepehrYahyaee
2df7a889d3 YARA-1244 2026-08-24 11:11:31 +03:30
93edba412f Merge pull request 'Added case type for all GET APIs' (#274) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#274
2026-08-19 12:31:47 +03:30
SepehrYahyaee
3bd6bc6e9d Added case type for all GET APIs 2026-08-19 12:30:53 +03:30
SepehrYahyaee
26c80512ba Merge branch 'main' of git.ittalie.com:s.yahyaee/yara724-api 2026-08-19 10:22:06 +03:30
85fc83b564 merge upstream 2026-08-19 10:20:56 +03:30
SepehrYahyaee
f09f5b79f0 Added FA version of integrations documents 2026-08-18 11:24:06 +03:30
SepehrYahyaee
bfbcfcab37 Added documentation 2026-08-17 17:18:49 +03:30
45 changed files with 13597 additions and 1086 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,596 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<title>مرجع یکپارچه‌سازی‌های خارجی</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Vazirmatn", "Tahoma", "Segoe UI", system-ui, sans-serif;
font-size: 14px; line-height: 1.8;
background: #ffffff; color: #1f2328; padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.03em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.7;
}
.card {
border: 1px solid #e5e7eb; border-radius: 6px;
padding: 16px; background: #f7f8fa; margin-bottom: 16px;
}
.card.card-blue { border-right: 4px solid #3b82f6; }
.card.card-green { border-right: 4px solid #22c55e; }
.card.card-purple { border-right: 4px solid #8b5cf6; }
.card.card-orange { border-right: 4px solid #f97316; }
.card.card-teal { border-right: 4px solid #14b8a6; }
.card.card-indigo { border-right: 4px solid #6366f1; }
.card.card-gray { border-right: 4px solid #94a3b8; }
.card.card-red { border-right: 4px solid #ef4444; }
.card.card-yellow { border-right: 4px solid #eab308; }
table {
border-collapse: collapse; width: 100%;
font-size: 12px; margin-top: 4px; direction: rtl;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: right; padding: 5px 8px; border: 1px solid #e5e7eb;
}
td { padding: 4px 8px; border: 1px solid #e5e7eb; vertical-align: top; }
tr:nth-child(even) td { background: #ffffff; }
code {
font-family: monospace; font-size: 11px; color: #3b82d4;
direction: ltr; unicode-bidi: embed;
}
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
direction: ltr; unicode-bidi: embed;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.note { font-size: 11px; color: #57606a; font-style: normal; margin-top: 6px; }
.warn { font-size: 11px; color: #9a3412; font-style: normal; margin-top: 6px; }
.status-badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
}
.status-live { background: #dcfce7; color: #166534; }
.status-partial { background: #ffedd5; color: #9a3412; }
.status-disabled { background: #fee2e2; color: #991b1b; }
.status-internal { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px; margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-right: 18px; padding-left: 0; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb; margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
.flow-box {
background: #ffffff; border: 1px solid #e5e7eb; border-radius: 4px;
padding: 10px 14px; font-size: 12px; margin-top: 8px;
}
.flow-step {
display: flex; gap: 10px; align-items: flex-start; margin-bottom: 6px;
direction: rtl;
}
.flow-num {
flex-shrink: 0; width: 20px; height: 20px; border-radius: 50%;
background: #3b82d4; color: #fff; font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.flow-text { flex: 1; padding-top: 2px; }
.decision-tree {
font-size: 12px; background: #ffffff;
border: 1px solid #e5e7eb; border-radius: 4px; padding: 12px 16px;
margin-top: 8px; line-height: 1.9;
}
.decision-tree ul { padding-right: 20px; padding-left: 0; }
.decision-tree li { margin-bottom: 2px; }
.env-table th:last-child { width: 220px; }
pre {
font-family: monospace; font-size: 11px;
background: #f1f5f9; border: 1px solid #e5e7eb;
border-radius: 4px; padding: 10px 12px;
white-space: pre-wrap; word-break: break-all;
margin-top: 6px; color: #1f2328;
direction: ltr; unicode-bidi: embed;
}
</style>
</head>
<body>
<div class="max-wrap">
<h1>مرجع یکپارچه‌سازی‌های خارجی</h1>
<p class="subtitle">
تمام یکپارچه‌سازی‌های خروجی: کاربرد، زمان فعال‌شدن، نحوه احراز هویت،
رفتار retry، fallbackها و تمام متغیرهای محیطی. سرویس‌های داخلی
(کپچا، داده‌های پرس‌وجوی آفلاین) برای کامل‌بودن گنجانده شده‌اند.
</p>
<!-- TOC -->
<div class="toc">
<div class="toc-title">فهرست</div>
<ol>
<li><a href="#inquiry-routing">درخت تصمیم مسیریابی پرس‌وجو</a></li>
<li><a href="#fanavaran">فناوران — پلتفرم خسارت بیمه</a></li>
<li><a href="#sanhub">SandHub — درگاه پرس‌وجوی قدیمی</a></li>
<li><a href="#tejarat">پرس‌وجوی تجارت — درگاه block-inquiry (V2+)</a></li>
<li><a href="#esg">ESG — ارائه‌دهنده پرس‌وجوی تنانت پارسیان</a></li>
<li><a href="#sms">پیامک — درگاه‌های کاوه‌نگار و پارسیان</a></li>
<li><a href="#ai">سرویس هوش مصنوعی — تشخیص خسارت خودرو</a></li>
<li><a href="#car-pricing">سرویس قیمت خودرو — جستجوی ارزش بازار</a></li>
<li><a href="#offline-inquiry">پرس‌وجوی آفلاین — داده‌های fallback</a></li>
<li><a href="#env-ref">مرجع متغیرهای محیطی</a></li>
</ol>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="inquiry-routing">۱ — درخت تصمیم مسیریابی پرس‌وجو</h2>
<p class="section-intro">
هر فایل تقصیر با فراخوانی "run-inquiries" آغاز می‌شود که بیمه‌نامه طرف مقصر
را از یک ارائه‌دهنده خارجی دریافت می‌کند. اینکه کدام ارائه‌دهنده واقعاً فراخوانی
می‌شود به سه عامل بستگی دارد: تنانت (<code>CLIENT_ID</code>)، نوع فایل
(THIRD_PARTY در مقابل CAR_BODY) و اینکه آیا حالت API زنده در تنظیمات سیستم
فعال است یا خیر. لایه داده‌های پرس‌وجوی آفلاین در جلوی هر سه ارائه‌دهنده قرار دارد.
</p>
<div class="card card-indigo">
<h3>انتخاب ارائه‌دهنده</h3>
<div class="decision-tree">
<strong>برای هر پرس‌وجوی مبتنی بر پلاک:</strong>
<ul>
<li>۱. بررسی داده‌های آفلاین (MongoDB) — اگر داده مطابق یافت شد، آن را برگردانده و تمام HTTP را رد کن.</li>
<li>۲. اگر <code>CLIENT_ID=8</code> (تنانت پارسیان/ESG) → مسیریابی به <strong>ESG</strong> <code>/inquiry/policyByPlate</code> یا <code>/inquiry/policyByChassis</code>.</li>
<li>۳. در غیر این صورت → مسیریابی به <strong>پرس‌وجوی تجارت</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) یا <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
<li>۴. اگر <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (پیش‌فرض) → پاسخ mock برگردانده شود به جای انجام فراخوانی‌های HTTP.</li>
</ul>
<br>
<strong>برای بررسی‌های هویت شخصی، گواهینامه، مالکیت و شبا:</strong>
<ul>
<li>اگر <code>CLIENT_ID=8</code> → ESG <code>/inquiry/person</code> و <code>/inquiry/sheba</code>.</li>
<li>در غیر این صورت → تجارت/SandHub <code>/personal-inquiry/tejarat-no</code>، <code>/driver-license-check</code>، <code>/ownership</code>، <code>/sheba/sheba-tejaratno</code>.</li>
</ul>
<br>
<strong>تفاوت کلیدی — فرمت تاریخ تولد:</strong>
SandHub/تجارت تاریخ تولد <em>میلادی</em> انتظار دارند (داخلی از جلالی تبدیل می‌شود).
ESG مستقیماً تاریخ <em>جلالی</em> انتظار دارد.
</div>
<p class="note" style="margin-top:8px;">
اندپوینت‌های SandHub فقط در مسیرهای قدیمی کد استفاده می‌شوند. تمام جریان‌های فعال تقصیر V2+ از طریق ارائه‌دهندگان تجارت یا ESG می‌روند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="fanavaran">۲ — فناوران <span class="status-badge status-live">فعال</span></h2>
<p class="section-intro">
فناوران (<code>apimanager.iraneit.com</code>) پلتفرم ملی پرونده خسارت بیمه است.
پس از اینکه کارشناس خسارت ارزیابی خود را ارسال می‌کند، سیستم به‌صورت خودکار
یک خسارت ساختاریافته را از طریق یک پروتکل چهار مرحله‌ای به فناوران ارسال می‌کند.
فناوران همچنین به‌عنوان منبع جستجوی code-listها (انواع تصادف، اجزای خودرو،
کدهای شهر و غیره) در سراسر پلتفرم عمل می‌کند.
</p>
<div class="card card-blue">
<h3>چرخه حیات احراز هویت</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>GET AppToken</strong> — <code>POST /EITAuthentication/GetAppToken</code> با هدرهای <code>appname</code> + <code>secret</code>. هدر <code>apptoken</code> را برمی‌گرداند.</div></div>
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>Login</strong> — <code>POST /EITAuthentication/Login</code> با هدرهای <code>appToken</code> + <code>userName</code> + <code>password</code>. هدر <code>authenticationToken</code> را برمی‌گرداند.</div></div>
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>Cache</strong> — توکن در حافظه <em>و</em> پایدار در MongoDB (<code>fanavaran_auth_tokens</code>) ذخیره می‌شود. تا نیمه‌شب <strong>Asia/Tehran</strong> معتبر است — اولین فراخوانی پس از ۰۰:۰۰ یک توکن تازه دریافت می‌کند.</div></div>
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>تمام فراخوانی‌های بعدی</strong> چهار هدر شامل می‌شوند: <code>authenticationToken</code>، <code>CorpId</code>، <code>ContractId</code>، <code>Location</code> — مختص تنانت، hardcoded به ازای هر کلید <code>FANAVARAN_CLIENT</code>.</div></div>
</div>
<p class="note" style="margin-top:8px;">
یک اثر انگشت پیکربندی (هش appName + secret + username + password + corpId + contractId + location)
یک ورود تازه را زمانی که هر مدرکی تغییر کند، حتی قبل از نیمه‌شب، مجبور می‌کند.
</p>
</div>
<div class="card card-blue">
<h3>پروتکل ارسال خسارت (۴ مرحله)</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">۱</div><div class="flow-text"><strong>خسارت پایه (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. داده‌های مالک، راننده، بیمه، وسیله نقلیه و تصادف را ارسال می‌کند. یک <code>claimId</code> و <code>claimNo</code> فناوران برمی‌گرداند. پیامک با هر دو شناسه برای مالک ارسال می‌شود.</div></div>
<div class="flow-step"><div class="flow-num">۲</div><div class="flow-text"><strong>موارد خسارت (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. یک ورودی به ازای هر قطعه آسیب‌دیده با شناسه کامپوننت، شدت و قیمت. سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان.</div></div>
<div class="flow-step"><div class="flow-num">۳</div><div class="flow-text"><strong>پیوست‌ها (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. اسناد، تصاویر car-capture و ویدیوها که با شناسه فایل ارجاع داده شده‌اند.</div></div>
<div class="flow-step"><div class="flow-num">۴</div><div class="flow-text"><strong>کارشناسی (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. متادیتای ارزیابی کارشناس (نقش کارشناس، تاریخ، نتیجه). ارسال را نهایی می‌کند.</div></div>
</div>
<p class="note" style="margin-top:8px;">
هر چهار مرحله در مجموعه <code>fanavaran_audit_logs</code> با بدنه کامل درخواست/پاسخ، وضعیت HTTP، مدت زمان و کد ردیابی برای اشکال‌زدایی ثبت می‌شوند.
</p>
</div>
<div class="card card-blue">
<h3>اندپوینت‌های Lookup</h3>
<p class="note">همه زیر <code>https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/</code>. نتایج روی دیسک (به ازای کلید مشتری) و در مجموعه MongoDB <code>lookups</code> کش می‌شوند. تنانت پارسیان قبل از درخواست API از DB می‌خواند؛ دیگران ابتدا به API می‌روند.</p>
<table>
<tr><th>کاربرد</th><th>مسیر</th></tr>
<tr><td>گزینه‌های dropdown برای accidentReason (نگاشت شده به شناسه‌های محلی)</td><td><code>/car/base-info/accident-causes</code></td></tr>
<tr><td>گزینه‌های accidentWay</td><td><code>/car/code-list/accident-report-type</code></td></tr>
<tr><td>طبقه‌بندی استفاده از وسیله نقلیه</td><td><code>/car/base-info/vehicle-use-types</code></td></tr>
<tr><td>روش پرداخت خسارت</td><td><code>/car/code-list/dmg-pay-method</code></td></tr>
<tr><td>گزینه‌های نوع گواهینامه</td><td><code>/car/base-info/driving-licence-types</code></td></tr>
<tr><td>طبقه‌بندی طرف مقصر</td><td><code>/car/code-list/accident-culprit-type</code></td></tr>
<tr><td>گزینه‌های محل بازرسی</td><td><code>/car/code-list/inspection-place</code></td></tr>
<tr><td>کدهای وضعیت کاهش قیمت</td><td><code>/car/code-list/drop-amount-status</code></td></tr>
<tr><td>کاتالوگ کامپوننت (نگاشت به قطعات بیرونی/داخلی)</td><td><code>/car/base-info/car-components</code></td></tr>
<tr><td>گزینه‌های شدت تصادف</td><td><code>/car/code-list/accident-level</code></td></tr>
<tr><td>تطبیق <code>INSURANCE_CORP_ID</code> ← corpId فناوران</td><td><code>/common/code-list/insurance-corp</code></td></tr>
<tr><td>انتخابگرهای شهر/استان</td><td><code>/common/base-info/cities</code>، <code>/common/base-info/Provinces</code></td></tr>
<tr><td>دریافت بیمه‌نامه کامل بر اساس شناسه پس از استعلام</td><td><code>/car/third-party-car-policies/{policyId}</code></td></tr>
<tr><td>جستجوی وسیله نقلیه بر اساس VIN</td><td><code>/car/vehicles/inquiry-by-vin?vin=…</code></td></tr>
<tr><td>فهرست بیمه‌نامه‌ها برای یک کد ملی</td><td><code>/common/Policies/inquiry-my-policies</code></td></tr>
<tr><td>دریافت رکورد مشتری بر اساس شناسه</td><td><code>/common/customers/{customerId}</code></td></tr>
<tr><td>جستجوی طرف بر اساس کد ملی + تاریخ تولد</td><td><code>/common/parties/inquiry-by-unique-identifier</code></td></tr>
</table>
</div>
<div class="card card-blue">
<h3>مدیریت خطا و انعطاف‌پذیری</h3>
<table>
<tr><th>توضیح</th><th>مکانیزم</th></tr>
<tr><td>۳ تلاش، ۵۰۰ ms ← ۱۰۰۰ ms backoff نمایی در تمام فراخوانی‌های HTTP.</td><td>Retry</td></tr>
<tr><td>وقتی فناوران پیام فارسی "دوباره تلاش کنید" (یا tracking-code 500) برمی‌گرداند، یک مکث ۵ دقیقه‌ای در سطح تنانت فعال می‌شود. تمام فراخوانی‌ها در این پنجره بلافاصله <code>503 ServiceUnavailable</code> دریافت می‌کنند — بدون فشار.</td><td>Backoff گذرا</td></tr>
<tr><td>در ۴۰۱، توکن از حافظه و MongoDB پاک می‌شود؛ فراخوانی بعدی GetAppToken + Login تازه را فعال می‌کند.</td><td>ابطال توکن</td></tr>
<tr><td>درخواست‌های همزمان ورود برای همان تنانت به یک Promise در حال پرواز جمع می‌شوند.</td><td>حذف تکراری Inflight</td></tr>
<tr><td>هر مرحله (GET_APP_TOKEN، LOGIN و هر چهار مرحله ارسال) در <code>fanavaran_audit_logs</code> با وضعیت STARTED / SUCCESS / FAILURE، هدرهای کامل، بدنه و مدت زمان نوشته می‌شود.</td><td>لاگ Audit</td></tr>
<tr><td>۲۰–۳۰ ثانیه به ازای هر فراخوانی HTTP.</td><td>Timeout</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>پروفایل‌های تنانت (<code>FANAVARAN_CLIENT</code>)</h3>
<p class="section-intro" style="margin-top:6px; margin-bottom:8px;">سه پروفایل تنانت از پیش تعیین‌شده وجود دارد. پروفایل فعال توسط متغیر محیطی <code>FANAVARAN_CLIENT</code> انتخاب می‌شود. هر پروفایل <code>appName</code>، <code>secret</code>، <code>username</code>، <code>password</code>، <code>CorpId</code>، <code>ContractId</code> و <code>Location</code> هدرهای خود را به علاوه پیش‌فرض‌های payload (AccidentCityId و غیره) دارد.</p>
<table>
<tr><th>شرکت بیمه</th><th>کلید</th></tr>
<tr><td>بیمه پارسیان</td><td><code>parsian</code></td></tr>
<tr><td>بیمه تجارت نو</td><td><code>tejaratno</code></td></tr>
<tr><td>بیمه معلم</td><td><code>moallem</code></td></tr>
</table>
<p class="note" style="margin-top:8px;">
<code>INSURANCE_CORP_ID</code> یک رشته عنوان نمایشی است (مثلاً <em>"بیمه پارسیان"</em>) که در برابر فهرست زنده فناوران <code>insurance-corp</code> تطبیق داده می‌شود تا <code>corpId</code> عددی مورد استفاده در ارسال‌ها را تولید کند. شناسه تطبیق‌یافته روی دیسک کش می‌شود.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sanhub">۳ — SandHub <span class="status-badge status-partial">قدیمی</span></h2>
<p class="section-intro">
SandHub درگاه پرس‌وجوی اصلی است. هنوز در کدبیس حضور دارد اما تمام جریان‌های
فعال تقصیر (V2+) به ارائه‌دهنده پرس‌وجوی تجارت منتقل شده‌اند. اندپوینت‌های
SandHub قابل فراخوانی هستند اما فقط از طریق مسیرهای قدیمی کد قابل دسترسی هستند.
حالت mock آن توسط همان تنظیم سیستم <code>sandHubUseLiveApi</code> کنترل می‌شود.
</p>
<div class="card card-gray">
<h3>احراز هویت</h3>
<p class="note">
<code>POST {SANHUB_BASE_URL}/user/login</code> با بدنه JSON نام کاربری + رمز عبور.
توکن در حافظه برای <strong>۵۵ دقیقه</strong> کش می‌شود. در ۴۰۱، توکن پاک می‌شود و یک تلاش مجدد انجام می‌شود.
۳ تلاش با ۱۰۰۰ ms ← ۲۰۰۰ ms backoff نمایی.
</p>
</div>
<div class="card card-gray">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>پرس‌وجوی بیمه‌نامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>leftTwoDigits</code>، <code>serialLetter</code>، <code>threeDigits</code>، <code>rightTwoDigits</code>، <code>nationalCode</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>پرس‌وجوی بیمه‌نامه CAR_BODY. Timeout ۵۰ ثانیه (طولانی‌تر از استاندارد).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/personal-inquiry/tejarat-no</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code> + <code>birthDate</code> <em>میلادی</em> (داخلی از جلالی تبدیل می‌شود).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/driver-license-check</code></td><td>اعتبارسنجی گواهینامه. پرچم <code>IsSucceed</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/ownership</code></td><td>بررسی مالکیت وسیله نقلیه. پرچم <code>IsSuccess</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/sheba/sheba-tejaratno</code></td><td>اعتبارسنجی شبا / حساب بانکی. <code>ReturnValue</code> + <code>HasError</code> را برمی‌گرداند.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
تمام اندپوینت‌ها پاسخ‌های mock کامل را زمانی که <code>sandHubUseLiveApi=false</code> در تنظیمات سیستم (پیش‌فرض) پشتیبانی می‌کنند. داده‌های mock قطعی هستند و به‌صورت محلی بدون هیچ فراخوانی HTTP تولید می‌شوند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="tejarat">۴ — پرس‌وجوی تجارت <span class="status-badge status-live">فعال</span></h2>
<p class="section-intro">
درگاه فعال block-inquiry برای تمام تنانت‌های غیر ESG. در هر فراخوانی V2+
<code>run-inquiries</code> که <code>CLIENT_ID ≠ 8</code> استفاده می‌شود.
URL پایه قابل پیکربندی است؛ در تولید به همان هاست SandHub اشاره می‌کند اما از
اعتبارنامه‌های جداگانه استفاده می‌کند.
</p>
<div class="card card-teal">
<h3>احراز هویت</h3>
<p class="note">
<code>POST {TEJARAT_INQUIRY_BASE_URL}/user/login</code> با بدنه JSON ایمیل + رمز عبور.
توکن برای <strong>۵۵ دقیقه</strong> کش می‌شود. ۲ تلاش با ۵۰۰ ms ← ۱۰۰۰ ms backoff.
جدا از اعتبارنامه‌های SandHub — از <code>TEJARAT_INQUIRY_EMAIL</code> / <code>TEJARAT_INQUIRY_PASSWORD</code> استفاده می‌کند.
</p>
</div>
<div class="card card-teal">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>پرس‌وجوی پلاک THIRD_PARTY. بدنه: فیلدهای پلاک + <code>nationalCode</code>. ابتدا داده آفلاین بررسی می‌شود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>پرس‌وجوی پلاک CAR_BODY. بدنه: <code>part1–part4</code> (عددی) + <code>nationalCode</code>. همیشه زنده می‌شود (mock برای مسیر badane وجود ندارد).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
وقتی <code>sandHubUseLiveApi=false</code>، مسیر THIRD_PARTY یک پاسخ mock بدون HTTP برمی‌گرداند. مسیر CAR_BODY همیشه API زنده را صرف‌نظر از این پرچم فراخوانی می‌کند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="esg">۵ — ESG <span class="status-badge status-live">فعال (CLIENT_ID=8)</span></h2>
<p class="section-intro">
ESG یک درگاه API بیمه داخلی است که منحصراً توسط تنانت پارسیان
(<code>CLIENT_ID=8</code>) استفاده می‌شود. برای تمام انواع پرس‌وجو زمانی که
این تنانت فعال است، جایگزین تجارت/SandHub می‌شود. شکل پاسخ متفاوتی دارد،
TTL توکن پویا دارد و تاریخ تولد را در فرمت <strong>جلالی</strong> انتظار دارد
(نه میلادی، برخلاف SandHub/تجارت).
</p>
<div class="card card-purple">
<h3>احراز هویت</h3>
<p class="note">
<code>POST {ESG_URL}/auth/login</code> با بدنه JSON <code>{ username, password }</code>.
TTL توکن از فیلد <code>expiresIn</code> پاسخ خوانده می‌شود (پیش‌فرض ۱۴ دقیقه).
۲ تلاش با ۵۰۰ ms ← ۱۰۰۰ ms backoff. در ۴۰۱، توکن پاک و یک تلاش مجدد.
URL پیش‌فرض: <code>http://192.168.20.22:8085</code> (شبکه داخلی).
</p>
</div>
<div class="card card-purple">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>جستجوی بیمه‌نامه مبتنی بر پلاک (THIRD_PARTY). بدنه: <code>nationalCode</code>، <code>plk1–plk4</code>. پاسخ قبل از ذخیره به فرمت قدیمی تجارت نگاشت می‌شود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>جایگزین مبتنی بر VIN/شاسی برای پرس‌وجوی پلاک. توسط اندپوینت‌های <code>run-inquiries-vin</code> فراخوانی می‌شود. از جستجوی شاسی ESG استفاده می‌کند (نه مسیر SandHub). بدنه: <code>nationalCode</code>، <code>chassis</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>بررسی هویت شخصی. بدنه: <code>nationalCode</code>، <code>birthDate</code> (جلالی، نه میلادی).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>اعتبارسنجی شبا / حساب بانکی.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
ESG هر پاسخ را به صورت <code>{ success: boolean, data: … }</code> می‌پیچد. یک بدنه <code>success=false</code> به یک خطای فارسی "استعلام در دسترس نیست" ترجمه می‌شود.
بررسی داده آفلاین-پرس‌وجو هنوز ابتدا اجرا می‌شود، قبل از هر فراخوانی HTTP ESG.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sms">۶ — پیامک <span class="status-badge status-live">فعال</span></h2>
<p class="section-intro">
دو ارائه‌دهنده پیامک پشتیبانی می‌شوند: <strong>کاوه‌نگار</strong> (پیش‌فرض)
و <strong>درگاه پیامک پارسیان</strong>. ارائه‌دهنده فعال توسط متغیر محیطی
<code>SMS_PROVIDER</code> (یا <code>SMS</code>) انتخاب می‌شود. هر دو ارائه‌دهنده
رابط درگاه داخلی یکسانی را پیاده‌سازی می‌کنند بنابراین لایه ارکستراسیون
مستقل از ارائه‌دهنده است.
</p>
<div class="card card-green">
<h3>انتخاب ارائه‌دهنده</h3>
<table>
<tr><th>ارائه‌دهنده فعال</th><th>مقدار</th><th>متغیر محیطی</th></tr>
<tr><td>کاوه‌نگار — <code>api.kavenegar.com</code></td><td><code>kavenegar</code> (پیش‌فرض)</td><td><code>SMS_PROVIDER</code> (یا <code>SMS</code>)</td></tr>
<tr><td>درگاه پیامک پارسیان — <code>PARSIAN_SMS_URL</code></td><td><code>parsian</code></td><td><code>SMS_PROVIDER</code> (یا <code>SMS</code>)</td></tr>
</table>
</div>
<div class="card card-green">
<h3>اندپوینت‌های کاوه‌نگار</h3>
<p class="note">URL پایه: <code>https://api.kavenegar.com/v1/{SMS_API_KEY}/</code></p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>زمان استفاده</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>sms/send.json</code></td><td>پیام‌های متن ساده (مثلاً متن‌های اطلاع‌رسانی مبتنی بر کلید ذخیره‌شده در مجموعه <code>sms_texts</code>).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>verify/lookup.json</code></td><td>تمام پیام‌های مبتنی بر قالب (OTPها، لینک‌های دعوت، اطلاع‌رسانی کارشناس). پارامترها: <code>receptor</code>، <code>token</code>[، <code>token2</code>، <code>token3</code>، <code>token10</code>]، <code>template</code>.</td></tr>
</table>
</div>
<div class="card card-green">
<h3>درگاه پیامک پارسیان</h3>
<p class="note">URL پایه از <code>PARSIAN_SMS_URL</code>. احراز هویت: هدر <code>X-PACKAGE-API-KEY</code> + <code>Authorization: Basic {PARSIAN_BASIC_TOKEN}</code>. به‌صورت GET با پارامترهای URL-encoded <code>ReceiverNumbers</code> و <code>Message</code> ارسال می‌کند. پیام‌های قالب قبل از ارسال به یک بدنه متن ساده پیش‌رندر می‌شوند (معادل verify/lookup ندارد).</p>
</div>
<div class="card card-green">
<h3>قالب‌های پیامک در حال استفاده</h3>
<table>
<tr><th>توکن‌ها</th><th>ماشه</th><th>نام قالب</th></tr>
<tr><td><code>token</code> = کد OTP</td><td>ورود OTP کاربر / اکتور، فراموشی رمز، OTPهای طرف</td><td><code>AUTH_SMS_TEMPLATE</code> (محیطی)</td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = لینک</td><td>طرف دوم لینک دعوت تقصیر را از طریق پیامک دریافت می‌کند</td><td><code>yara724-invite-link</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = نام خانوادگی کارشناس، <code>token3</code> = لینک</td><td>کارشناس میدانی لینک را برای یک طرف ارسال می‌کند</td><td><code>yara-field-expert-link</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = لینک</td><td>اطلاع به طرف که طرف دیگر با رأی کارشناس موافقت کرده است</td><td><code>yara-blame-agreement</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = لینک</td><td>طرف زیان‌دیده مطلع می‌شود که جریان خسارت را پس از تکمیل تقصیر باز کند</td><td><code>yara-claim-link</code></td></tr>
<tr><td><code>token</code> = "تصادف"/"خسارت"، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس</td><td>کارشناس یک فایل تقصیر یا خسارت را قفل می‌کند</td><td><code>yara-expert-lock</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = لینک</td><td>کارشناس درخواست ارسال مجدد اسناد می‌دهد</td><td><code>yara-resend-documents</code></td></tr>
<tr><td><code>token</code> = نوع فایل، <code>token2</code> = publicId، <code>token3</code> = نام خانوادگی کارشناس، <code>token10</code> = لینک</td><td>طرف مطلع می‌شود که ارزیابی خسارت کارشناس را امضا کند</td><td><code>yara-signature</code></td></tr>
<tr><td><code>token</code> = publicId، <code>token2</code> = claimId فناوران، <code>token3</code> = claimNo فناوران</td><td>ارسال فناوران تأیید شد — با شماره و شناسه خسارت فناوران برای مالک خسارت ارسال می‌شود</td><td><code>yara-fanavaran-claim</code></td></tr>
</table>
<p class="note" style="margin-top:8px;">
تمام فراخوانی‌های پیامک fire-and-forget هستند — هرگز throw نمی‌کنند. شکست‌ها log می‌شوند اما جریان اصلی را مسدود نمی‌کنند.
یک مجموعه MongoDB <code>sms_send_logs</code> هر پیام خروجی را با نوع آن (OTP در مقابل TEMPLATE)، ارائه‌دهنده، نام قالب و وضعیت موفقیت/شکست ثبت می‌کند.
پیام‌های متنی اطلاع‌رسانی (اختلاف طرفین، امضای یک طرف و غیره) در راه‌اندازی در مجموعه <code>sms_texts</code> seed می‌شوند و در زمان اجرا قابل ویرایش هستند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="ai">۷ — سرویس هوش مصنوعی <span class="status-badge status-disabled">غیرفعال (کد موجود)</span></h2>
<p class="section-intro">
یک سرویس تشخیص خسارت خودرو مبتنی بر تصویر در کدبیس یکپارچه‌سازی شده است اما
فراخوانی‌های HTTP آن <strong>کاملاً comment شده‌اند</strong>. ماژول در راه‌اندازی
مقداردهی اولیه می‌شود، تلاش برای ورود می‌کند (در صورت شکست به صورت خاموش
بلعیده می‌شود)، و یک متد <code>aiRequestImage</code> را expose می‌کند — اما
فراخوانی‌های axios زیرین غیرفعال هستند. سرویس هیچ جریان تولیدی را تحت تأثیر
قرار نمی‌دهد.
</p>
<div class="card card-yellow">
<h3>رابط مورد نظر (زمانی که دوباره فعال شود)</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/auth/login</code></td><td>احراز هویت با نام کاربری + رمز عبور. <code>accessToken</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{AI_URL_V2}/auth/profile</code></td><td>دریافت <code>apiKey.key</code> مورد نیاز به‌عنوان هدر درخواست <code>gateway-api-key</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/services/car-damage/detector?version=ai-v7</code></td><td>ارسال تصویر قطعه خودرو (multipart). <code>downloadLink</code> با نتیجه حاشیه‌نویسی‌شده را برمی‌گرداند.</td></tr>
</table>
<p class="warn" style="margin-top:8px;">
وضعیت: هر سه فراخوانی در بلوک‌های <code>axios.request(…)</code> comment-شده پیچیده شده‌اند.
<code>CW_URL</code> در <code>.env.example</code> نیست. برای فعال‌سازی مجدد، فراخوانی‌های axios login، getApiKey و aiRequestImage را uncomment کنید و <code>AI_URL_V2</code>، <code>AI_USERNAME</code>، <code>AI_PASSWORD</code> را پیکربندی کنید.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="car-pricing">۸ — سرویس قیمت خودرو <span class="status-badge status-partial">نیمه‌فعال</span></h2>
<p class="section-intro">
فقط در طول محاسبه کاهش قیمت کارشناس-خسارت استفاده می‌شود. وقتی یک کارشناس
مقادیر شدت برای هر قطعه ارائه می‌دهد، سیستم قیمت‌های بازار بلادرنگ برای مدل
خودروی آسیب‌دیده را دریافت می‌کند، سپس کاهش قیمت را با استفاده از فرمول
محاسبه می‌کند: <strong>قیمت خودرو × ضریب سال × مجموع ضرایب قطعات ÷ ۴۰۰</strong>.
سرویس دو منبع داده (اندپوینت) دارد که به‌صورت موازی امتحان می‌شوند.
</p>
<div class="card card-orange">
<h3>اندپوینت‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?akharin</code></td><td>دریافت قیمت‌های بازار خودرو از منبع "آخرین". آرایه <code>{ carName, marketPrice }</code> را برمی‌گرداند.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?hamrah</code></td><td>دریافت قیمت‌های بازار خودرو از منبع "همراه". همان شکل پاسخ.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
هر دو اندپوینت امتحان می‌شوند؛ نتایج ادغام و حذف تکراری می‌شوند. بهترین تطابق برای
نام خودروی آسیب‌دیده با استفاده از <strong>فاصله Levenshtein</strong> (تطابق رشته فازی) پیدا می‌شود.
اگر هر دو اندپوینت شکست بخورند یا خالی برگردانند، محاسبه کاهش قیمت رد می‌شود (ناقص علامت‌گذاری می‌شود) — ارسال خسارت را مسدود نمی‌کند.
</p>
<p class="warn" style="margin-top:6px;">
<strong><code>CW_URL</code> در <code>.env.example</code> مستندسازی نشده است.</strong>
این سرویس در صورت تنظیم نشدن متغیر، به‌صورت خاموش هیچ کاهش قیمتی تولید نخواهد کرد.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="offline-inquiry">۹ — پرس‌وجوی آفلاین <span class="status-badge status-internal">داخلی / fallback</span></h2>
<p class="section-intro">
لایه پرس‌وجوی آفلاین فراخوانی‌های پرس‌وجوی مبتنی بر پلاک را قبل از اینکه هر
HTTP خارجی انجام شود رهگیری می‌کند. عمدتاً برای توسعه و تست (پلاک‌های شناخته‌شده
از پیش seed شده) استفاده می‌شود اما همچنین به‌عنوان fallback انعطاف‌پذیری
زمانی که سرویس‌های پرس‌وجوی زنده در دسترس نیستند عمل می‌کند. توسط یک پرچم
پایگاه‌داده زمان اجرا کنترل می‌شود، نه یک متغیر محیطی.
</p>
<div class="card card-gray">
<h3>نحوه کار</h3>
<table>
<tr><th>جزئیات</th><th>جنبه</th></tr>
<tr><td>مجموعه MongoDB <code>offline-inquiries</code>. اسناد شامل <code>clientKey</code>، فیلدهای نرمال‌شده پلاک، <code>nationalCode</code> و پاسخ از پیش ساخته‌شده <code>raw</code> + <code>mapped</code> برای برگرداندن هستند.</td><td>ذخیره‌سازی</td></tr>
<tr><td><code>system_settings.offlineInquiry.enabled</code> — پیش‌فرض <code>true</code>. تغییر از طریق <code>PATCH /super-admin/system-settings/offline-inquiry</code>.</td><td>سوئیچ اصلی</td></tr>
<tr><td>پلاک نرمال‌شده (فقط ارقام، عربی→فارسی) + کد ملی + کلید مشتری فناوران باید همه مطابقت داشته باشند. اگر پیدا شد، بلافاصله برگردانده می‌شود؛ هیچ فراخوانی HTTP انجام نمی‌شود.</td><td>ترتیب جستجو</td></tr>
<tr><td>فقط برای پرس‌وجوی block مبتنی بر پلاک (THIRD_PARTY) اعمال می‌شود. پرس‌وجوی CAR_BODY (<code>/badane</code>) همیشه API زنده را می‌زند.</td><td>محدوده</td></tr>
<tr><td><code>system_settings.externalApis.sandHubUseLiveApi</code> — وقتی <code>false</code> (پیش‌فرض)، حتی اگر هیچ داده آفلاینی مطابقت نداشته باشد، یک پاسخ mock داخلی برگردانده می‌شود به جای فراخوانی تجارت/ESG.</td><td>پرچم API زنده</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="env-ref">۱۰ — مرجع متغیرهای محیطی</h2>
<p class="section-intro">
تمام متغیرهای محیطی در سراسر تمام یکپارچه‌سازی‌ها، گروه‌بندی‌شده بر اساس سرویس.
متغیرهای علامت‌گذاری‌شده با <strong>*</strong> در <code>.env.example</code> وجود ندارند.
</p>
<div class="card card-blue">
<h3>فناوران</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>کلید پروفایل تنانت فعال: <code>parsian</code> | <code>tejaratno</code> | <code>moallem</code></td><td><code>FANAVARAN_CLIENT</code></td></tr>
<tr><td>عنوان نمایشی شرکت بیمه‌گر (مثلاً <em>"بیمه پارسیان"</em>) — در راه‌اندازی در برابر فهرست insurance-corp فناوران به یک corpId عددی تطبیق داده می‌شود.</td><td><code>INSURANCE_CORP_ID</code></td></tr>
</table>
<p class="note" style="margin-top:8px;">اعتبارنامه‌های هر تنانت (appName، secret، username، password، CorpId، ContractId، Location) در <code>src/core/config/fanavaran-client.config.ts</code> زیر <code>SEED_FANAVARAN_CLIENT_PROFILES</code> hardcoded شده‌اند.</p>
</div>
<div class="card card-gray">
<h3>SandHub (قدیمی)</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه برای SandHub. پیش‌فرض: <code>http://82.99.202.245:3027</code></td><td><code>SANHUB_BASE_URL</code></td></tr>
<tr><td>URL کامل ورود (معمولاً base + <code>/user/login</code>)</td><td><code>SANHUB_URL_LOGIN</code></td></tr>
<tr><td>ایمیل ورود SandHub</td><td><code>SANHUB_USERNAME</code></td></tr>
<tr><td>رمز عبور ورود SandHub</td><td><code>SANHUB_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-teal">
<h3>پرس‌وجوی تجارت</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه. پیش‌فرض: <code>http://82.99.202.245:3027</code></td><td><code>TEJARAT_INQUIRY_BASE_URL</code></td></tr>
<tr><td>ایمیل ورود</td><td><code>TEJARAT_INQUIRY_EMAIL</code></td></tr>
<tr><td>رمز عبور ورود</td><td><code>TEJARAT_INQUIRY_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-purple">
<h3>ESG (فقط CLIENT_ID=8)</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>به <code>8</code> تنظیم کنید تا ارائه‌دهنده پرس‌وجوی ESG برای تنانت پارسیان فعال شود.</td><td><code>CLIENT_ID</code></td></tr>
<tr><td>URL پایه ESG. پیش‌فرض: <code>http://192.168.20.22:8085</code> (شبکه داخلی)</td><td><code>ESG_URL</code></td></tr>
<tr><td>نام کاربری ورود ESG</td><td><code>ESG_USERNAME</code></td></tr>
<tr><td>رمز عبور ورود ESG</td><td><code>ESG_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-green">
<h3>پیامک</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td><code>kavenegar</code> (پیش‌فرض) یا <code>parsian</code></td><td><code>SMS_PROVIDER</code> (یا <code>SMS</code>)</td></tr>
<tr><td>کلید API کاوه‌نگار (الزامی وقتی provider = kavenegar)</td><td><code>SMS_API_KEY</code></td></tr>
<tr><td>نام قالب کاوه‌نگار برای پیام‌های OTP (مثلاً <code>yara-otp</code>)</td><td><code>AUTH_SMS_TEMPLATE</code></td></tr>
<tr><td>URL پایه درگاه پیامک پارسیان (الزامی وقتی provider = parsian)</td><td><code>PARSIAN_SMS_URL</code></td></tr>
<tr><td>مقدار هدر پیامک پارسیان <code>X-PACKAGE-API-KEY</code></td><td><code>PARSIAN_API_KEY</code></td></tr>
<tr><td>اعتبارنامه‌های رمزگذاری‌شده Base64 برای هدر <code>Authorization: Basic …</code></td><td><code>PARSIAN_BASIC_TOKEN</code></td></tr>
<tr><td>URL پایه فرانت‌اند — برای ساخت تمام لینک‌های دعوت + خسارت تعبیه‌شده در پیام‌های پیامک استفاده می‌شود</td><td><code>URL</code></td></tr>
</table>
</div>
<div class="card card-yellow">
<h3>سرویس هوش مصنوعی</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه درگاه هوش مصنوعی. پیش‌فرض: <code>https://ai-gw.ittalie.ir</code> (استفاده نشده — سرویس غیرفعال است)</td><td><code>AI_URL_V2</code></td></tr>
<tr><td>نام کاربری ورود سرویس هوش مصنوعی (استفاده نشده)</td><td><code>AI_USERNAME</code></td></tr>
<tr><td>رمز عبور ورود سرویس هوش مصنوعی (استفاده نشده)</td><td><code>AI_PASSWORD</code></td></tr>
</table>
</div>
<div class="card card-orange">
<h3>سرویس قیمت خودرو</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>URL پایه برای API قیمت بازار خودرو (مثلاً <code>https://…/</code>). در <code>.env.example</code> نیست. کاهش قیمت به‌صورت خاموش رد می‌شود اگر تنظیم نشده باشد.</td><td><code>CW_URL</code> *</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>عمومی / برنامه</h3>
<table class="env-table">
<tr><th>شرح</th><th>متغیر</th></tr>
<tr><td>پورت HTTP (پیش‌فرض ۳۰۰۰). توسط fallback insurance-corp فناوران برای فراخوانی اندپوینت جستجوی محلی خودش استفاده می‌شود.</td><td><code>PORT</code></td></tr>
<tr><td><code>true</code> / <code>false</code> — چالش کپچای ورود را فعال/غیرفعال می‌کند. داخلی، بدون سرویس خارجی.</td><td><code>CAPTCHA_ENABLED</code></td></tr>
<tr><td>TTL چالش کپچا به دقیقه.</td><td><code>EXP_CAPTCHA_TIME</code></td></tr>
<tr><td>TTL کد یکبار مصرف به دقیقه.</td><td><code>EXP_OTP_TIME</code></td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,593 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>External Integrations Reference</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 14px; line-height: 1.6;
background: #ffffff; color: #1f2328; padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.5;
}
.card {
border: 1px solid #e5e7eb; border-radius: 6px;
padding: 16px; background: #f7f8fa; margin-bottom: 16px;
}
.card.card-blue { border-left: 4px solid #3b82f6; }
.card.card-green { border-left: 4px solid #22c55e; }
.card.card-purple { border-left: 4px solid #8b5cf6; }
.card.card-orange { border-left: 4px solid #f97316; }
.card.card-teal { border-left: 4px solid #14b8a6; }
.card.card-indigo { border-left: 4px solid #6366f1; }
.card.card-gray { border-left: 4px solid #94a3b8; }
.card.card-red { border-left: 4px solid #ef4444; }
.card.card-yellow { border-left: 4px solid #eab308; }
table {
border-collapse: collapse; width: 100%;
font-size: 12px; margin-top: 4px;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: left; padding: 5px 8px; border: 1px solid #e5e7eb;
}
td { padding: 4px 8px; border: 1px solid #e5e7eb; vertical-align: top; }
tr:nth-child(even) td { background: #ffffff; }
code { font-family: monospace; font-size: 11px; color: #3b82d4; }
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.note { font-size: 11px; color: #57606a; font-style: italic; margin-top: 6px; }
.warn { font-size: 11px; color: #9a3412; font-style: italic; margin-top: 6px; }
.status-badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
}
.status-live { background: #dcfce7; color: #166534; }
.status-partial { background: #ffedd5; color: #9a3412; }
.status-disabled { background: #fee2e2; color: #991b1b; }
.status-internal { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px; margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-left: 18px; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb; margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
.flow-box {
background: #ffffff; border: 1px solid #e5e7eb; border-radius: 4px;
padding: 10px 14px; font-size: 12px; margin-top: 8px;
}
.flow-step {
display: flex; gap: 10px; align-items: flex-start; margin-bottom: 6px;
}
.flow-num {
flex-shrink: 0; width: 20px; height: 20px; border-radius: 50%;
background: #3b82d4; color: #fff; font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.flow-text { flex: 1; padding-top: 2px; }
.decision-tree {
font-size: 12px; background: #ffffff;
border: 1px solid #e5e7eb; border-radius: 4px; padding: 12px 16px;
margin-top: 8px; line-height: 1.8;
}
.decision-tree ul { padding-left: 20px; }
.decision-tree li { margin-bottom: 2px; }
.env-table th:first-child { width: 220px; }
pre {
font-family: monospace; font-size: 11px;
background: #f1f5f9; border: 1px solid #e5e7eb;
border-radius: 4px; padding: 10px 12px;
white-space: pre-wrap; word-break: break-all;
margin-top: 6px; color: #1f2328;
}
</style>
</head>
<body>
<div class="max-wrap">
<h1>External Integrations Reference</h1>
<p class="subtitle">
Every outbound integration: what it does, when it fires, how auth works,
retry behaviour, fallbacks, and all environment variables. Internal-only
services (captcha, offline inquiry seed) are included for completeness.
</p>
<!-- TOC -->
<div class="toc">
<div class="toc-title">Contents</div>
<ol>
<li><a href="#inquiry-routing">Inquiry routing decision tree</a></li>
<li><a href="#fanavaran">Fanavaran — insurance claims platform</a></li>
<li><a href="#sanhub">SandHub — legacy inquiry gateway</a></li>
<li><a href="#tejarat">Tejarat inquiry — block-inquiry gateway (V2+)</a></li>
<li><a href="#esg">ESG — Parsian-tenant inquiry provider</a></li>
<li><a href="#sms">SMS — Kavenegar and Parsian gateways</a></li>
<li><a href="#ai">AI service — car damage detection</a></li>
<li><a href="#car-pricing">Car pricing service — market value lookup</a></li>
<li><a href="#offline-inquiry">Offline inquiry — fallback seed data</a></li>
<li><a href="#env-ref">Environment variable reference</a></li>
</ol>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="inquiry-routing">1 — Inquiry Routing Decision Tree</h2>
<p class="section-intro">
Every blame file starts with a "run-inquiries" call that fetches the
guilty party's insurance policy from an external provider. Which provider
is actually called depends on three factors: the tenant (<code>CLIENT_ID</code>),
the file type (THIRD_PARTY vs CAR_BODY), and whether live API mode is
enabled in system settings. The offline-inquiry seed layer sits in front
of all three providers.
</p>
<div class="card card-indigo">
<h3>Provider selection</h3>
<div class="decision-tree">
<strong>For every plate-based block inquiry:</strong>
<ul>
<li>1. Check offline-inquiry seeds (MongoDB) — if a matching seed exists, return it and skip all HTTP.</li>
<li>2. If <code>CLIENT_ID=8</code> (Parsian/ESG tenant) → route to <strong>ESG</strong> <code>/inquiry/policyByPlate</code> or <code>/inquiry/policyByChassis</code>.</li>
<li>3. Otherwise → route to <strong>Tejarat inquiry</strong> <code>/block-inquiry-tejarat</code> (THIRD_PARTY) or <code>/block-inquiry-tejarat/badane</code> (CAR_BODY).</li>
<li>4. If <code>system_settings.externalApis.sandHubUseLiveApi = false</code> (default) → return mock response instead of making HTTP calls.</li>
</ul>
<br>
<strong>For personal-identity, driving-licence, ownership, and Sheba checks:</strong>
<ul>
<li>If <code>CLIENT_ID=8</code> → ESG <code>/inquiry/person</code> and <code>/inquiry/sheba</code>.</li>
<li>Otherwise → Tejarat/SandHub <code>/personal-inquiry/tejarat-no</code>, <code>/driver-license-check</code>, <code>/ownership</code>, <code>/sheba/sheba-tejaratno</code>.</li>
</ul>
<br>
<strong>Key difference — birth date format:</strong>
SandHub/Tejarat expect a <em>Gregorian</em> birth date (converted internally from Jalali).
ESG expects the <em>Jalali</em> date directly.
</div>
<p class="note" style="margin-top:8px;">
SandHub endpoints are only used in legacy code paths. All active V2+ blame flows go through the Tejarat or ESG providers.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="fanavaran">2 — Fanavaran <span class="status-badge status-live">live</span></h2>
<p class="section-intro">
Fanavaran (<code>apimanager.iraneit.com</code>) is the national insurance
damage-case platform. After the damage expert submits their assessment,
the system auto-submits a structured claim to Fanavaran through a
four-step protocol. Fanavaran also serves as the lookup source for
code-lists (accident types, car components, city codes, etc.) used
across the platform.
</p>
<div class="card card-blue">
<h3>Authentication lifecycle</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>GET AppToken</strong> — <code>POST /EITAuthentication/GetAppToken</code> with <code>appname</code> + <code>secret</code> headers. Returns <code>apptoken</code> header.</div></div>
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Login</strong> — <code>POST /EITAuthentication/Login</code> with <code>appToken</code> + <code>userName</code> + <code>password</code> headers. Returns <code>authenticationToken</code> header.</div></div>
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Cache</strong> — token is cached in memory <em>and</em> persisted to MongoDB (<code>fanavaran_auth_tokens</code>). Valid until midnight <strong>Asia/Tehran</strong> — the first call after 00:00 fetches a fresh token.</div></div>
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>All subsequent calls</strong> include four headers: <code>authenticationToken</code>, <code>CorpId</code>, <code>ContractId</code>, <code>Location</code> — tenant-specific, hardcoded per <code>FANAVARAN_CLIENT</code> key.</div></div>
</div>
<p class="note" style="margin-top:8px;">
A config fingerprint (hash of appName + secret + username + password + corpId + contractId + location)
forces a fresh login when any credential changes, even before midnight.
</p>
</div>
<div class="card card-blue">
<h3>Claim submission protocol (4 steps)</h3>
<div class="flow-box">
<div class="flow-step"><div class="flow-num">1</div><div class="flow-text"><strong>Base claim (GEN.03)</strong> — <code>POST /car/third-party-car-financial-claims</code>. Sends owner, driver, insurance, vehicle, and accident data. Returns a Fanavaran <code>claimId</code> and <code>claimNo</code>. SMS is sent to the owner with both identifiers.</div></div>
<div class="flow-step"><div class="flow-num">2</div><div class="flow-text"><strong>Damage cases (GEN.05)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/dmg-cases</code>. One entry per damaged part with component ID, severity, and price. Cap: total ≤ 53 000 000 Toman.</div></div>
<div class="flow-step"><div class="flow-num">3</div><div class="flow-text"><strong>Attachments (GEN.07)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/files</code>. Documents, car-capture images, and videos referenced by file ID.</div></div>
<div class="flow-step"><div class="flow-num">4</div><div class="flow-text"><strong>Expertise (GEN.08)</strong> — <code>POST /car/third-party-car-financial-claims/{claimId}/expertise</code>. Expert assessment metadata (expert role, date, result). Finalises the submission.</div></div>
</div>
<p class="note" style="margin-top:8px;">
All four steps are recorded in the <code>fanavaran_audit_logs</code> collection with full request/response bodies, HTTP status, duration, and tracking code for debugging.
</p>
</div>
<div class="card card-blue">
<h3>Lookup endpoints</h3>
<p class="note">All under <code>https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/</code>. Results are cached to disk (per client key) and in the <code>lookups</code> MongoDB collection. Parsian tenant reads DB before hitting the API; others go to the API first.</p>
<table>
<tr><th>Path</th><th>Used for</th></tr>
<tr><td><code>/car/base-info/accident-causes</code></td><td>accidentReason dropdown options (mapped to local IDs)</td></tr>
<tr><td><code>/car/code-list/accident-report-type</code></td><td>accidentWay options</td></tr>
<tr><td><code>/car/base-info/vehicle-use-types</code></td><td>vehicle usage classification</td></tr>
<tr><td><code>/car/code-list/dmg-pay-method</code></td><td>damage payment method</td></tr>
<tr><td><code>/car/base-info/driving-licence-types</code></td><td>licence type options</td></tr>
<tr><td><code>/car/code-list/accident-culprit-type</code></td><td>guilty-party classification</td></tr>
<tr><td><code>/car/code-list/inspection-place</code></td><td>inspection location options</td></tr>
<tr><td><code>/car/code-list/drop-amount-status</code></td><td>price-drop status codes</td></tr>
<tr><td><code>/car/base-info/car-components</code></td><td>component catalog (maps to outer/inner parts)</td></tr>
<tr><td><code>/car/code-list/accident-level</code></td><td>accident severity options</td></tr>
<tr><td><code>/common/code-list/insurance-corp</code></td><td>resolve <code>INSURANCE_CORP_ID</code> → Fanavaran corpId</td></tr>
<tr><td><code>/common/base-info/cities</code>, <code>/common/base-info/Provinces</code></td><td>city/province pickers</td></tr>
<tr><td><code>/car/third-party-car-policies/{policyId}</code></td><td>fetch full policy by ID after inquiry</td></tr>
<tr><td><code>/car/vehicles/inquiry-by-vin?vin=…</code></td><td>VIN-based vehicle lookup</td></tr>
<tr><td><code>/common/Policies/inquiry-my-policies</code></td><td>list policies for a national code</td></tr>
<tr><td><code>/common/customers/{customerId}</code></td><td>fetch customer record by ID</td></tr>
<tr><td><code>/common/parties/inquiry-by-unique-identifier</code></td><td>party lookup by national code + birth date</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Error handling &amp; resilience</h3>
<table>
<tr><th>Mechanism</th><th>Detail</th></tr>
<tr><td>Retry</td><td>3 attempts, 500 ms → 1 000 ms exponential backoff on all HTTP calls.</td></tr>
<tr><td>Transient backoff</td><td>When Fanavaran returns the Persian "try again later" message (or tracking-code 500), a tenant-wide 5-minute pause is activated. All calls during this window get <code>503 ServiceUnavailable</code> immediately — no hammering.</td></tr>
<tr><td>Token invalidation</td><td>On 401, token is cleared from memory and MongoDB; next call triggers a fresh GetAppToken + Login.</td></tr>
<tr><td>Inflight de-dup</td><td>Concurrent login requests for the same tenant are collapsed to a single in-flight Promise.</td></tr>
<tr><td>Audit log</td><td>Every step (GET_APP_TOKEN, LOGIN, and all four submission steps) is written to <code>fanavaran_audit_logs</code> with STARTED / SUCCESS / FAILURE status, full headers, body, and duration.</td></tr>
<tr><td>Timeout</td><td>20–30 s per HTTP call.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Tenant profiles (<code>FANAVARAN_CLIENT</code>)</h3>
<p class="section-intro" style="margin-top:6px; margin-bottom:8px;">Three pre-seeded tenant profiles exist. The active one is chosen by the <code>FANAVARAN_CLIENT</code> env var. Each profile carries its own <code>appName</code>, <code>secret</code>, <code>username</code>, <code>password</code>, <code>CorpId</code>, <code>ContractId</code>, and <code>Location</code> headers, plus payload defaults (AccidentCityId, etc.).</p>
<table>
<tr><th>Key</th><th>Insurance company</th></tr>
<tr><td><code>parsian</code></td><td>Parsian Insurance</td></tr>
<tr><td><code>tejaratno</code></td><td>Tejaratno Insurance</td></tr>
<tr><td><code>moallem</code></td><td>Moallem Insurance</td></tr>
</table>
<p class="note" style="margin-top:8px;">
<code>INSURANCE_CORP_ID</code> is a display-caption string (e.g. <em>"بیمه پارسیان"</em>) that is resolved against the live Fanavaran <code>insurance-corp</code> list to produce the numeric <code>corpId</code> used in submissions. The resolved ID is cached to disk.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sanhub">3 — SandHub <span class="status-badge status-partial">legacy</span></h2>
<p class="section-intro">
SandHub is the original inquiry gateway. It is still present in the
codebase but all active blame flows (V2+) have been migrated to the
Tejarat inquiry provider. SandHub endpoints remain callable but are
only reached through legacy code paths. Its mock mode is controlled
by the same <code>sandHubUseLiveApi</code> system setting.
</p>
<div class="card card-gray">
<h3>Auth</h3>
<p class="note">
<code>POST {SANHUB_BASE_URL}/user/login</code> with username + password JSON body.
Token cached in memory for <strong>55 minutes</strong>. On 401, token is cleared and one retry is made.
3 attempts with 1 000 ms → 2 000 ms exponential backoff.
</p>
</div>
<div class="card card-gray">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>Plate-based insurance policy inquiry (THIRD_PARTY). Body: <code>leftTwoDigits</code>, <code>serialLetter</code>, <code>threeDigits</code>, <code>rightTwoDigits</code>, <code>nationalCode</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>CAR_BODY policy inquiry. Timeout 50 s (longer than standard).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/personal-inquiry/tejarat-no</code></td><td>Personal identity check. Body: <code>nationalCode</code> + <em>Gregorian</em> <code>birthDate</code> (converted from Jalali internally).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/driver-license-check</code></td><td>Driving licence validation. Returns <code>IsSucceed</code> flag.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/ownership</code></td><td>Vehicle ownership check. Returns <code>IsSuccess</code> flag.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/sheba/sheba-tejaratno</code></td><td>Sheba / bank account validation. Returns <code>ReturnValue</code> + <code>HasError</code>.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
All endpoints support full mock responses when <code>sandHubUseLiveApi=false</code> in system settings (default). Mock data is deterministic and produced locally without any HTTP calls.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="tejarat">4 — Tejarat Inquiry <span class="status-badge status-live">live</span></h2>
<p class="section-intro">
The active block-inquiry gateway for all non-ESG tenants. Used in every
V2+ <code>run-inquiries</code> call where <code>CLIENT_ID ≠ 8</code>.
The base URL is configurable; in production it points to the same host
as SandHub but uses separate credentials.
</p>
<div class="card card-teal">
<h3>Auth</h3>
<p class="note">
<code>POST {TEJARAT_INQUIRY_BASE_URL}/user/login</code> with email + password JSON body.
Token cached for <strong>55 minutes</strong>. 2 attempts with 500 ms → 1 000 ms backoff.
Separate from SandHub credentials — uses <code>TEJARAT_INQUIRY_EMAIL</code> / <code>TEJARAT_INQUIRY_PASSWORD</code>.
</p>
</div>
<div class="card card-teal">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat</code></td><td>THIRD_PARTY plate inquiry. Body: plate fields + <code>nationalCode</code>. Offline seed checked first.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/block-inquiry-tejarat/badane</code></td><td>CAR_BODY plate inquiry. Body: <code>part1–part4</code> (numeric) + <code>nationalCode</code>. Always goes live (no mock for badane path).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
When <code>sandHubUseLiveApi=false</code>, the THIRD_PARTY path returns a mock response without HTTP. The CAR_BODY path always calls the live API regardless of this flag.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="esg">5 — ESG <span class="status-badge status-live">live (CLIENT_ID=8)</span></h2>
<p class="section-intro">
ESG is an internal insurance API gateway used exclusively by the Parsian
tenant (<code>CLIENT_ID=8</code>). It replaces Tejarat/SandHub for all
inquiry types when this tenant is active. It has a different response
shape, a dynamic token TTL, and expects birth dates in <strong>Jalali</strong>
format (not Gregorian, unlike SandHub/Tejarat).
</p>
<div class="card card-purple">
<h3>Auth</h3>
<p class="note">
<code>POST {ESG_URL}/auth/login</code> with <code>{ username, password }</code> JSON body.
Token TTL is read from the response <code>expiresIn</code> field (default 14 min).
2 attempts with 500 ms → 1 000 ms backoff. On 401, token cleared and one retry.
Default URL: <code>http://192.168.20.22:8085</code> (internal network).
</p>
</div>
<div class="card card-purple">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByPlate</code></td><td>Plate-based policy lookup (THIRD_PARTY). Body: <code>nationalCode</code>, <code>plk1–plk4</code>. Response is mapped to the old Tejarat format before being stored.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/policyByChassis</code></td><td>VIN/chassis-based alternative to plate inquiry. Called by <code>run-inquiries-vin</code> endpoints. Uses ESG chassis lookup (not the SandHub path). Body: <code>nationalCode</code>, <code>chassis</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/person</code></td><td>Personal identity check. Body: <code>nationalCode</code>, <code>birthDate</code> (Jalali, NOT Gregorian).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>/inquiry/sheba</code></td><td>Sheba / bank account validation.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
ESG wraps every response as <code>{ success: boolean, data: … }</code>. A <code>success=false</code> body is translated to a Persian "استعلام در دسترس نیست" (inquiry unavailable) error.
The offline-inquiry seed check still runs first, before any ESG HTTP call.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="sms">6 — SMS <span class="status-badge status-live">live</span></h2>
<p class="section-intro">
Two SMS providers are supported: <strong>Kavenegar</strong> (default)
and <strong>Parsian SMS Gateway</strong>. The active provider is chosen
by the <code>SMS_PROVIDER</code> (or <code>SMS</code>) env var. Both
providers implement the same internal gateway interface so the
orchestration layer is provider-agnostic.
</p>
<div class="card card-green">
<h3>Provider selection</h3>
<table>
<tr><th>Env var</th><th>Value</th><th>Active provider</th></tr>
<tr><td><code>SMS_PROVIDER</code> (or <code>SMS</code>)</td><td><code>kavenegar</code> (default)</td><td>Kavenegar — <code>api.kavenegar.com</code></td></tr>
<tr><td><code>SMS_PROVIDER</code> (or <code>SMS</code>)</td><td><code>parsian</code></td><td>Parsian SMS Gateway — <code>PARSIAN_SMS_URL</code></td></tr>
</table>
</div>
<div class="card card-green">
<h3>Kavenegar endpoints</h3>
<p class="note">Base URL: <code>https://api.kavenegar.com/v1/{SMS_API_KEY}/</code></p>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>When used</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>sms/send.json</code></td><td>Plain-text messages (e.g. key-based notification texts stored in <code>sms_texts</code> collection).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>verify/lookup.json</code></td><td>All template-based messages (OTPs, invite links, expert notifications). Params: <code>receptor</code>, <code>token</code>[, <code>token2</code>, <code>token3</code>, <code>token10</code>], <code>template</code>.</td></tr>
</table>
</div>
<div class="card card-green">
<h3>Parsian SMS Gateway</h3>
<p class="note">Base URL from <code>PARSIAN_SMS_URL</code>. Auth: <code>X-PACKAGE-API-KEY</code> header + <code>Authorization: Basic {PARSIAN_BASIC_TOKEN}</code>. Sends as a GET with URL-encoded <code>ReceiverNumbers</code> and <code>Message</code> query params. Template messages are pre-rendered into a plain text body before sending (no verify/lookup equivalent).</p>
</div>
<div class="card card-green">
<h3>SMS templates in use</h3>
<table>
<tr><th>Template name</th><th>Trigger</th><th>Tokens</th></tr>
<tr><td><code>AUTH_SMS_TEMPLATE</code> (env)</td><td>User / actor OTP login, forget-password, party OTPs</td><td><code>token</code> = OTP code</td></tr>
<tr><td><code>yara724-invite-link</code></td><td>Second party receives blame invite link via SMS</td><td><code>token</code> = publicId, <code>token2</code> = link</td></tr>
<tr><td><code>yara-field-expert-link</code></td><td>Field expert sends link to a party</td><td><code>token</code> = file type, <code>token2</code> = expert surname, <code>token3</code> = link</td></tr>
<tr><td><code>yara-blame-agreement</code></td><td>Notify party that the other side agreed to the expert verdict</td><td><code>token</code> = publicId, <code>token2</code> = link</td></tr>
<tr><td><code>yara-claim-link</code></td><td>Damaged party notified to open claim flow after blame is complete</td><td><code>token</code> = publicId, <code>token2</code> = link</td></tr>
<tr><td><code>yara-expert-lock</code></td><td>Expert locks a blame or claim file</td><td><code>token</code> = "تصادف"/"خسارت", <code>token2</code> = publicId, <code>token3</code> = expert surname</td></tr>
<tr><td><code>yara-resend-documents</code></td><td>Expert requests document resend</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = link</td></tr>
<tr><td><code>yara-signature</code></td><td>Party notified to sign the expert's damage assessment</td><td><code>token</code> = file kind, <code>token2</code> = publicId, <code>token3</code> = expert surname, <code>token10</code> = link</td></tr>
<tr><td><code>yara-fanavaran-claim</code></td><td>Fanavaran submission confirmed — sent to claim owner with Fanavaran claim number and ID</td><td><code>token</code> = publicId, <code>token2</code> = Fanavaran claimId, <code>token3</code> = Fanavaran claimNo</td></tr>
</table>
<p class="note" style="margin-top:8px;">
All SMS calls are fire-and-forget — they never throw. Failures are logged but do not block the main flow.
An <code>sms_send_logs</code> MongoDB collection records every outbound message with its kind (OTP vs TEMPLATE), provider, template name, and success/failure status.
Notification text messages (parties-disagree, one-party-signed, etc.) are seeded into the <code>sms_texts</code> collection on startup and editable at runtime.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="ai">7 — AI Service <span class="status-badge status-disabled">disabled (code present)</span></h2>
<p class="section-intro">
An image-based car damage detection service is integrated in the
codebase but its HTTP calls are <strong>fully commented out</strong>.
The module initialises on startup, attempts a login (silently swallowed
if it fails), and exposes an <code>aiRequestImage</code> method — but
the underlying axios calls are disabled. The service does not affect
any production flow.
</p>
<div class="card card-yellow">
<h3>Intended interface (when re-enabled)</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/auth/login</code></td><td>Authenticate with username + password. Returns <code>accessToken</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{AI_URL_V2}/auth/profile</code></td><td>Fetch <code>apiKey.key</code> needed as the <code>gateway-api-key</code> request header.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>{AI_URL_V2}/services/car-damage/detector?version=ai-v7</code></td><td>Submit a car part image (multipart). Returns <code>downloadLink</code> with annotated result.</td></tr>
</table>
<p class="warn" style="margin-top:8px;">
Status: all three calls are wrapped in commented-out <code>axios.request(…)</code> blocks.
<code>CW_URL</code> is not in <code>.env.example</code>. To re-enable, uncomment the login, getApiKey, and aiRequestImage axios calls, and configure <code>AI_URL_V2</code>, <code>AI_USERNAME</code>, <code>AI_PASSWORD</code>.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="car-pricing">8 — Car Pricing Service <span class="status-badge status-partial">partially active</span></h2>
<p class="section-intro">
Used only during damage-expert price-drop calculation. When an expert
provides per-part severity values the system fetches real-time market
prices for the damaged car model, then computes the price-drop using
the formula: <strong>carPrice × yearCoefficient × sumOfPartCoefficients ÷ 400</strong>.
The service has two data sources (endpoints) that are tried in parallel.
</p>
<div class="card card-orange">
<h3>Endpoints</h3>
<table>
<tr><th style="width:70px">Method</th><th>Path</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?akharin</code></td><td>Fetch car market prices from the "Akharin" source. Returns array of <code>{ carName, marketPrice }</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>{CW_URL}price?hamrah</code></td><td>Fetch car market prices from the "Hamrah" source. Same response shape.</td></tr>
</table>
<p class="note" style="margin-top:8px;">
Both endpoints are tried; results are merged and de-duplicated. The best match for
the damaged car's name is found using <strong>Levenshtein distance</strong> (fuzzy string match).
If both endpoints fail or return empty, the price-drop calculation is skipped (marked incomplete) — it does not block claim submission.
</p>
<p class="warn" style="margin-top:6px;">
<strong><code>CW_URL</code> is not documented in <code>.env.example</code>.</strong>
This service will silently produce no price-drop if the variable is unset.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="offline-inquiry">9 — Offline Inquiry <span class="status-badge status-internal">internal / fallback</span></h2>
<p class="section-intro">
The offline inquiry layer intercepts plate-based inquiry calls before
any external HTTP is made. It is primarily used for development and
testing (pre-seeded known plates) but also acts as a resilience fallback
when live inquiry services are unavailable. It is controlled by a
runtime database flag, not an env var.
</p>
<div class="card card-gray">
<h3>How it works</h3>
<table>
<tr><th>Aspect</th><th>Detail</th></tr>
<tr><td>Storage</td><td>MongoDB collection <code>offline-inquiries</code>. Documents contain <code>clientKey</code>, normalised plate fields, <code>nationalCode</code>, and the pre-built <code>raw</code> + <code>mapped</code> response to return.</td></tr>
<tr><td>Master switch</td><td><code>system_settings.offlineInquiry.enabled</code> — defaults to <code>true</code>. Toggle via <code>PATCH /super-admin/system-settings/offline-inquiry</code>.</td></tr>
<tr><td>Lookup order</td><td>Normalised plate (digits-only, Arabic→Persian) + national code + Fanavaran client key must all match. If found, returned immediately; no HTTP call is made.</td></tr>
<tr><td>Scope</td><td>Only applies to plate-based block-inquiry (THIRD_PARTY). CAR_BODY inquiry (<code>/badane</code>) always hits the live API.</td></tr>
<tr><td>Live API flag</td><td><code>system_settings.externalApis.sandHubUseLiveApi</code> — when <code>false</code> (default), even if no offline seed matches, a built-in mock response is returned rather than calling Tejarat/ESG.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="env-ref">10 — Environment Variable Reference</h2>
<p class="section-intro">
All env vars across all integrations, grouped by service.
Variables marked <strong>*</strong> are not present in <code>.env.example</code>.
</p>
<div class="card card-blue">
<h3>Fanavaran</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>FANAVARAN_CLIENT</code></td><td>Active tenant profile key: <code>parsian</code> | <code>tejaratno</code> | <code>moallem</code></td></tr>
<tr><td><code>INSURANCE_CORP_ID</code></td><td>Display caption of the insurer company (e.g. <em>"بیمه پارسیان"</em>) — resolved to a numeric corpId at startup against the Fanavaran insurance-corp list.</td></tr>
</table>
<p class="note" style="margin-top:8px;">Per-tenant credentials (appName, secret, username, password, CorpId, ContractId, Location) are hardcoded in <code>src/core/config/fanavaran-client.config.ts</code> under <code>SEED_FANAVARAN_CLIENT_PROFILES</code>.</p>
</div>
<div class="card card-gray">
<h3>SandHub (legacy)</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>SANHUB_BASE_URL</code></td><td>Base URL for SandHub. Default: <code>http://82.99.202.245:3027</code></td></tr>
<tr><td><code>SANHUB_URL_LOGIN</code></td><td>Full login URL (usually base + <code>/user/login</code>)</td></tr>
<tr><td><code>SANHUB_USERNAME</code></td><td>SandHub login email</td></tr>
<tr><td><code>SANHUB_PASSWORD</code></td><td>SandHub login password</td></tr>
</table>
</div>
<div class="card card-teal">
<h3>Tejarat inquiry</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>TEJARAT_INQUIRY_BASE_URL</code></td><td>Base URL. Default: <code>http://82.99.202.245:3027</code></td></tr>
<tr><td><code>TEJARAT_INQUIRY_EMAIL</code></td><td>Login email</td></tr>
<tr><td><code>TEJARAT_INQUIRY_PASSWORD</code></td><td>Login password</td></tr>
</table>
</div>
<div class="card card-purple">
<h3>ESG (CLIENT_ID=8 only)</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>CLIENT_ID</code></td><td>Set to <code>8</code> to activate the ESG inquiry provider for the Parsian tenant.</td></tr>
<tr><td><code>ESG_URL</code></td><td>ESG base URL. Default: <code>http://192.168.20.22:8085</code> (internal network)</td></tr>
<tr><td><code>ESG_USERNAME</code></td><td>ESG login username</td></tr>
<tr><td><code>ESG_PASSWORD</code></td><td>ESG login password</td></tr>
</table>
</div>
<div class="card card-green">
<h3>SMS</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>SMS_PROVIDER</code> (or <code>SMS</code>)</td><td><code>kavenegar</code> (default) or <code>parsian</code></td></tr>
<tr><td><code>SMS_API_KEY</code></td><td>Kavenegar API key (required when provider = kavenegar)</td></tr>
<tr><td><code>AUTH_SMS_TEMPLATE</code></td><td>Kavenegar template name for OTP messages (e.g. <code>yara-otp</code>)</td></tr>
<tr><td><code>PARSIAN_SMS_URL</code></td><td>Parsian SMS Gateway base URL (required when provider = parsian)</td></tr>
<tr><td><code>PARSIAN_API_KEY</code></td><td>Parsian SMS <code>X-PACKAGE-API-KEY</code> header value</td></tr>
<tr><td><code>PARSIAN_BASIC_TOKEN</code></td><td>Base64-encoded credentials for <code>Authorization: Basic …</code> header</td></tr>
<tr><td><code>URL</code></td><td>Frontend base URL — used to build all invite + claim links embedded in SMS messages</td></tr>
</table>
</div>
<div class="card card-yellow">
<h3>AI service</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>AI_URL_V2</code></td><td>AI gateway base URL. Default: <code>https://ai-gw.ittalie.ir</code> (unused — service is disabled)</td></tr>
<tr><td><code>AI_USERNAME</code></td><td>AI service login username (unused)</td></tr>
<tr><td><code>AI_PASSWORD</code></td><td>AI service login password (unused)</td></tr>
</table>
</div>
<div class="card card-orange">
<h3>Car pricing service</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>CW_URL</code> *</td><td>Base URL for car market price API (e.g. <code>https://…/</code>). Not in <code>.env.example</code>. Price-drop silently skipped if unset.</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>General / app</h3>
<table class="env-table">
<tr><th>Variable</th><th>Description</th></tr>
<tr><td><code>PORT</code></td><td>HTTP port (default 3000). Used by the Fanavaran insurance-corp fallback to call its own local lookup endpoint.</td></tr>
<tr><td><code>CAPTCHA_ENABLED</code></td><td><code>true</code> / <code>false</code> — enables/disables login CAPTCHA challenge. Internal, no external service.</td></tr>
<tr><td><code>EXP_CAPTCHA_TIME</code></td><td>CAPTCHA challenge TTL in minutes.</td></tr>
<tr><td><code>EXP_OTP_TIME</code></td><td>OTP TTL in minutes.</td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,625 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<title>مرجع نقش‌های پنل</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Vazirmatn", "Tahoma", "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.8;
background: #ffffff;
color: #1f2328;
padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.03em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.7;
}
/* Role header strip */
.role-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
}
.role-name {
font-size: 15px;
font-weight: 700;
}
.role-enum {
font-family: monospace;
font-size: 11px;
color: #3b82d4;
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
padding: 1px 6px;
direction: ltr;
unicode-bidi: embed;
}
.badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
margin-left: 4px; margin-bottom: 3px;
}
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-purple { background: #ede9fe; color: #5b21b6; }
.badge-orange { background: #ffedd5; color: #9a3412; }
.badge-teal { background: #ccfbf1; color: #0f766e; }
.badge-indigo { background: #e0e7ff; color: #3730a3; }
.badge-gray { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.badge-red { background: #fee2e2; color: #991b1b; }
/* Cards */
.card {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 16px;
background: #f7f8fa;
margin-bottom: 16px;
}
.card.card-blue { border-right: 4px solid #3b82f6; }
.card.card-green { border-right: 4px solid #22c55e; }
.card.card-purple { border-right: 4px solid #8b5cf6; }
.card.card-orange { border-right: 4px solid #f97316; }
.card.card-teal { border-right: 4px solid #14b8a6; }
.card.card-indigo { border-right: 4px solid #6366f1; }
.card.card-gray { border-right: 4px solid #94a3b8; }
.card.card-red { border-right: 4px solid #ef4444; }
/* Endpoint tables */
table {
border-collapse: collapse;
width: 100%;
font-size: 12px;
margin-top: 4px;
direction: rtl;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: right; padding: 5px 8px;
border: 1px solid #e5e7eb;
}
td {
padding: 4px 8px; border: 1px solid #e5e7eb;
vertical-align: top;
}
tr:nth-child(even) td { background: #ffffff; }
code {
font-family: monospace; font-size: 11px; color: #3b82d4;
direction: ltr; unicode-bidi: embed;
}
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
direction: ltr; unicode-bidi: embed;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.method.delete { color: #dc2626; }
.dep { color: #94a3b8; font-style: italic; font-size: 11px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 860px) { .grid-2 { grid-template-columns: 1fr; } }
.note {
font-size: 11px; color: #57606a; font-style: normal;
margin-top: 6px;
}
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px;
margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-right: 18px; padding-left: 0; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb;
margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
/* Role overview table */
.overview-table { font-size: 12px; margin-bottom: 24px; }
.overview-table th { white-space: nowrap; }
.overview-table td:last-child { font-weight: 600; white-space: nowrap; }
</style>
</head>
<body>
<div class="max-wrap">
<h1>مرجع نقش‌های پنل</h1>
<p class="subtitle">
آنچه هر نقش می‌تواند ببیند و انجام دهد — اندپوینت‌ها، مسئولیت‌ها و
مراحل فرآیند. سوپر ادمین در این مستند نیست.
</p>
<!-- Table of Contents -->
<div class="toc">
<div class="toc-title">نقش‌های پوشش‌داده‌شده</div>
<ol>
<li><a href="#insurer">بیمه‌گر (COMPANY) — ادمین تنانت شرکت بیمه</a></li>
<li><a href="#blame-expert">کارشناس تقصیر (EXPERT) — صف بررسی اختلاف</a></li>
<li><a href="#damage-expert">کارشناس خسارت (DAMAGE_EXPERT) — قیمت‌گذاری خسارت</a></li>
<li><a href="#field-expert">کارشناس میدانی (FIELD_EXPERT) — ثبت حضوری در صحنه</a></li>
<li><a href="#file-maker">فایل‌ساز (FILE_MAKER) — روایت طرفین در V4/V5</a></li>
<li><a href="#file-reviewer">بازبین فایل (FILE_REVIEWER) — ارزیابی خسارت V4/V5</a></li>
<li><a href="#registrar">ثبات (REGISTRAR) — ثبت اداری حضوری</a></li>
<li><a href="#call-center">مرکز تماس (CALL_CENTER) — ثبت تلفنی V6</a></li>
</ol>
</div>
<!-- ── Role overview table ────────────────────────────────────── -->
<h2>نمای کلی نقش‌ها</h2>
<table class="overview-table">
<tr>
<th>وظیفه اصلی</th>
<th>محدوده</th>
<th>پنل ورود</th>
<th>enum نقش</th>
</tr>
<tr>
<td>مشاهده تمام فایل‌ها؛ مدیریت شعب و کارشناسان؛ گزارش‌گیری؛ امتیازدهی به کارشناسان</td>
<td>سطح تنانت</td>
<td>پورتال بیمه‌گر</td>
<td><code>company</code></td>
</tr>
<tr>
<td>قفل‌کردن پرونده‌های تقصیر، بررسی اسناد طرفین، صدور رأی یا درخواست ارسال مجدد</td>
<td>صف DISAGREEMENT تنانت</td>
<td>پنل کارشناس تقصیر</td>
<td><code>expert</code></td>
</tr>
<tr>
<td>قفل‌کردن خسارت، قیمت‌گذاری، اعتبارسنجی فاکتورها، درخواست ارسال مجدد/بازدید</td>
<td>صف خسارت تنانت</td>
<td>پنل خسارت</td>
<td><code>damage_expert</code></td>
</tr>
<tr>
<td>ثبت حضوری تقصیر + خسارت در V2/V3؛ دسترسی به پنل‌های تقصیر/خسارت</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل کارشناس میدانی</td>
<td><code>field_expert</code></td>
</tr>
<tr>
<td>روایت طرفین V4/V5 (OTP، استعلام، جزئیات، امضا)؛ تأیید خسارت در V5</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل فایل‌ساز</td>
<td><code>file_maker</code></td>
</tr>
<tr>
<td>ارزیابی خسارت V4/V5 (فیلدهای تصادف، قطعات و عکس‌ها؛ تأیید خطوط قیمت‌گذاری‌شده در جریان ترکیبیِ فاکتور در صورت نیاز)</td>
<td>فایل‌های تخصیص‌یافته</td>
<td>پنل بازبین فایل</td>
<td><code>file_reviewer</code></td>
</tr>
<tr>
<td>ثبت حضوری اداری تقصیر + خسارت به نمایندگی از طرفین</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل ثبات</td>
<td><code>registrar</code></td>
</tr>
<tr>
<td>ثبت تلفنی V6: اجرای استعلام، ارسال لینک؛ کاربر بقیه را تکمیل می‌کند</td>
<td>فایل‌های ساخته‌شده توسط خود</td>
<td>پنل مرکز تماس</td>
<td><code>call_center</code></td>
</tr>
</table>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="insurer">۱ — بیمه‌گر <span class="role-enum">company</span></h2>
<p class="section-intro">
به ازای هر تنانت شرکت بیمه یک اکتور <code>company</code> وجود دارد. پورتال بیمه‌گر
لایه مدیریتی است: می‌تواند همه چیز زیر تنانت خود را ببیند، لیست کارشناسان را مدیریت
کند، شعب را اداره کند، تنظیمات رسانه‌ای هر تنانت را پیکربندی کند و گزارش‌های آماری
استخراج کند. بیمه‌گر هرگز مستقیماً با مراحل تقصیر/خسارت درگیر نمی‌شود — فقط نظاره‌گر
و امتیازدهنده است.
</p>
<div class="card card-blue">
<h3>مدیریت فایل — <code>expert-insurer/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files</code></td><td>فهرست تمام فایل‌های تقصیر + خسارت تنانت (ادغام‌شده بر اساس publicId). فیلترپذیر بر اساس وضعیت، نوع فایل، جستجو، مرتب‌سازی، صفحه.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId</code></td><td>جزئیات کامل یک فایل بر اساس publicId.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/timeline</code></td><td>تایم‌لاین فعالیت به ترتیب زمانی (تمام رویدادهای تاریخچه: منبع، نوع، اکتور، متادیتا).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/report</code></td><td>داده‌های ساختاریافته گزارش برای تولید PDF (بخش‌های مالک، راننده، بیمه، خودرو، تصادف).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/files/:publicId/rating</code></td><td>امتیازدهی به کارشناسان یک فایل (۱–۵ در هر بُعد: روش تصادف، به‌موقع‌بودن، دقت علت، دقت شناسایی مقصر، امتیاز ربات).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/unified-file-statuses</code></td><td>کاتالوگ وضعیت یکپارچه + تعداد به ازای هر وضعیت برای کل پرتفولیوی تنانت. فیلترپذیر بر اساس fileType و بازه تاریخ.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/status-counts</code></td><td class="dep">منسوخ‌شده — از unified-file-statuses استفاده کنید.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>مدیریت شعب — <code>expert-insurer/branches</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/branches</code></td><td>فهرست تمام شعب این بیمه‌گر. پارامترها: جستجو، بازه تاریخ from/to، فیلتر isActive.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/branches</code></td><td>افزودن شعبه جدید (نام، کد، آدرس، شهر، تلفن و غیره).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/branches/:branchId/status</code></td><td>فعال یا غیرفعال کردن یک شعبه.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>مدیریت لیست کارشناسان — <code>expert-insurer/experts</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/blame</code></td><td>ایجاد حساب کارشناس تقصیر جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/claim</code></td><td>ایجاد حساب کارشناس خسارت جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-maker</code></td><td>ایجاد حساب فایل‌ساز جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-reviewer</code></td><td>ایجاد حساب بازبین فایل جدید زیر این بیمه‌گر.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/list</code></td><td>فهرست صفحه‌بندی‌شده تمام حساب‌های کارشناس در این تنانت.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/top</code></td><td>برترین کارشناسان تقصیر و خسارت رتبه‌بندی‌شده بر اساس میانگین امتیاز کلی (حداکثر ۱۰ نفر از هر نوع).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-experts</code></td><td>نام مستعار experts/top (سازگاری با فرانت‌اند).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/:expertId</code></td><td>فایل‌های رسیدگی‌شده توسط یک کارشناس (ردیف‌های خلاصه — تقصیر یا خسارت بسته به نوع کارشناس).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>آمار و گزارش‌ها</h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/statistics</code></td><td>کارت‌های KPI: totalFilesReviewed، averageUserRating، inPersonCount، filesThisMonth، objectionPercentage و غیره. فیلترپذیر بر اساس بازه تاریخ.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-files</code></td><td>۱۰ فایل خسارت برتر بر اساس بالاترین امتیاز (ترکیبی از امتیاز بیمه‌گر + کاربر).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/expert-work-log</code></td><td>لاگ کاری هر کارشناس: totalHandled، currentlyChecking، distinctFilesCheckedInPeriod. فیلترپذیر بر اساس expertKind و بازه تاریخ.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/requests</code></td><td>تعداد خسارت + وضعیت تقصیر + تعداد فایل یکپارچه برای تنانت.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/per-month-requests</code></td><td>همان خلاصه، تفکیک‌شده بر اساس ۵ ماه تقویمی اخیر.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/checked-requests</code></td><td>همان خلاصه، فیلترشده بر اساس بازه زمانی اختیاری createdAt.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log</code></td><td>لاگ کاری کارشناسان (مجموعه‌های کارشناس تقصیر و خسارت، نه کارشناسان میدانی).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log/per-month</code></td><td>همان لاگ کاری تفکیک‌شده بر اساس ماه تقویمی (۵ ماه اخیر).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>تنظیمات تنانت — <code>client-panel/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>client-panel/settings</code></td><td>دریافت محدودیت‌های رسانه‌ای هر تنانت (حداکثر بایت ویدیو/تصویر/صوت) و پنجره زمانی تصادف CAR_BODY (روز).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>client-panel/settings</code></td><td>به‌روزرسانی جزئی این تنظیمات. نمی‌تواند از سقف‌های سطح سیستم تجاوز کند.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="blame-expert">۲ — کارشناس تقصیر <span class="role-enum">expert</span></h2>
<p class="section-intro">
فایل‌های تقصیر در صف DISAGREEMENT را بررسی می‌کند — پرونده‌هایی که دو طرف درباره
مقصر بودن توافق ندارند. پس از بررسی اسناد و اظهارات طرفین، کارشناس پرونده را قفل
می‌کند، سپس یا رأی صادر می‌کند، درخواست ارسال مجدد اسناد می‌دهد، یا نتیجه بازدید
حضوری را ثبت می‌کند. تمام اندپوینت‌ها زیر <code>v2/expert-blame/</code> هستند.
</p>
<div class="card card-orange">
<h3>فرآیند</h3>
<p class="note">
۱ مرور فهرست ← ۲ تخصیص (قفل) پرونده ← ۳ بررسی مدارک طرفین (ویدیو، صدا، اسناد) ←
۴الف صدور رأی <em>یا</em> ۴ب درخواست ارسال مجدد اسناد <em>یا</em> ۴پ ثبت بازدید حضوری.
</p>
</div>
<div class="card card-orange">
<h3>اندپوینت‌ها — <code>v2/expert-blame/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/</code></td><td>فهرست پرونده‌های تقصیر در صف DISAGREEMENT (موجود، قفل‌شده توسط من، یا تصمیم‌گرفته‌شده توسط من). پارامترها: search، sortBy، sortOrder، page، limit، unifiedStatus، fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/:id</code></td><td>جزئیات کامل یک پرونده تقصیر (اظهارات، عکس، صدا، ویدیو طرفین).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-blame/:id/assign</code></td><td>بررسی در دسترس بودن و قفل پرونده برای این کارشناس. بازمی‌گرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹ در صورتی که شخص دیگری آن را نگه داشته باشد.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/submit/:id</code></td><td>ارسال رأی (accidentWay، accidentReason، accidentType، تصمیم طرف مقصر). پرونده را آزاد می‌کند و به COMPLETED منتقل می‌کند.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/resend/:id</code></td><td>درخواست از طرفین برای بارگذاری مجدد اسناد. تقصیر را به WAITING_FOR_RESEND تنظیم می‌کند. یک درخواست ارسال مجدد در هر چرخه عمر.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/inPerson/:id</code></td><td>ثبت اینکه بازدید حضوری انجام شده و صدور رأی.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/unified-file-statuses</code></td><td>کاتالوگ وضعیت + تعداد به ازای هر وضعیت برای پرتفولیوی این کارشناس.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/status-counts</code></td><td class="dep">منسوخ‌شده — از unified-file-statuses استفاده کنید.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/lock/:id</code></td><td class="dep">اندپوینت قفل منسوخ‌شده — از POST assign استفاده کنید.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="damage-expert">۳ — کارشناس خسارت <span class="role-enum">damage_expert</span></h2>
<p class="section-intro">
فایل‌های خسارت را پس از ارسال مدارک خسارت توسط کاربر بررسی می‌کند. کارشناس
هر قطعه آسیب‌دیده را قیمت‌گذاری می‌کند، به‌صورت اختیاری کاهش قیمت (استهلاک)
محاسبه می‌کند، و می‌تواند از کاربر بخواهد مدارک را مجدداً ارسال کند، حضوری مراجعه
کند، یا فاکتورهای تعمیرگاه را هنگام نیاز به قیمت‌گذاری کارگاهی بارگذاری کند.
تمام اندپوینت‌ها زیر <code>v2/expert-claim/</code> هستند.
</p>
<div class="card card-red">
<h3>فرآیند</h3>
<p class="note">
۱ مرور فهرست ← ۲ تخصیص (قفل) خسارت ← ۳ بررسی عکس‌ها و اسناد خسارت ←
۴ ویرایش اختیاری قطعات انتخاب‌شده یا محاسبه کاهش قیمت ←
۵الف ارسال پاسخ قیمت‌گذاری‌شده <em>یا</em> ۵ب درخواست ارسال مجدد <em>یا</em> ۵پ درخواست بازدید حضوری ←
۶ در صورت وجود قطعات فاکتوردار: اعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده.
</p>
</div>
<div class="card card-red">
<h3>اندپوینت‌ها — <code>v2/expert-claim/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>فهرست خسارت‌ها در صف <code>WAITING_FOR_DAMAGE_EXPERT</code> + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>جزئیات کامل خسارت: قطعات آسیب‌دیده، تصاویر گرفته‌شده، اسناد، priceDrop، داده طرف بلیم، آدرس‌های ویدیو.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>قفل خسارت برای این کارشناس. بازمی‌گرداند: <code>assigned</code>، <code>already_assigned_to_you</code>، یا ۴۰۹.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محتوای کاهش قیمت: برچسب‌های شدت، کاتالوگ ضریب، قطعات آسیب‌دیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>محاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان. بسته به پرچم‌های factorNeeded، خسارت را به owner-sign، mixed-factors-pending، یا صف اعتبارسنجی فاکتور منتقل می‌کند.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>درخواست از کاربر برای ارسال مجدد اسناد/عکس‌ها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمی‌گرداند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>درخواست از کاربر برای مراجعه حضوری. خسارت را آزاد می‌کند، وضعیت claimStatus را به NEEDS_REVISION تنظیم می‌کند.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>اعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. تأیید یا رد هر خط فاکتور با totalPayment. سقف برای تمام خطوط اعمال می‌شود (≤ ۵۳،۰۰۰،۰۰۰ تومان). پس از تصمیم‌گیری درباره تمام خطوط، به‌صورت خودکار تکمیل می‌شود.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>ویرایش قطعات آسیب‌دیده انتخاب‌شده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>کاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>JSON ثابت کاتالوگ قطعات داخلی خودرو.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/branches</code></td><td>شعب بیمه‌گر برای تنانت این کارشناس (برای انتخاب داغی/شعبه در پیلود پاسخ).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/stream/:id/video</code></td><td>پخش ویدیوی خسارت (ویدیوی دور زدن خودرو یا ویدیوی تصادف). پارامتر: <code>query=car-capture|accident</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/unified-file-statuses</code></td><td>کاتالوگ وضعیت + تعداد برای پرتفولیوی خسارت این کارشناس.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/status-counts</code></td><td class="dep">منسوخ‌شده — از unified-file-statuses استفاده کنید.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/lock/:claimRequestId</code></td><td class="dep">اندپوینت قفل منسوخ‌شده — از POST assign استفاده کنید.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="field-expert">۴ — کارشناس میدانی <span class="role-enum">field_expert</span></h2>
<p class="section-intro">
به صحنه تصادف می‌رود و فرم‌های هر دو طرف را حضوری پر می‌کند (جریان V2 mirror / V3).
کارشناس میدانی همچنین دسترسی خواندن به پنل‌های expert-blame و expert-claim دارد
(محدود به فایل‌های خودش). تنها نقشی است که هم <strong>ثبت تقصیر</strong> و هم
<strong>ثبت خسارت</strong> را در یک جلسه انجام می‌دهد.
</p>
<div class="card card-green">
<h3>ثبت تقصیر — <code>v2/expert-initiated/blame-request-management/</code></h3>
<p class="note">آینه‌ای از API تقصیر کاربر. فرانت‌اند همان صفحات را با تغییر فقط پیشوند مسیر بازاستفاده می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>ایجاد فایل تقصیر IN_PERSON.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code></td><td>ارسال OTP به یک طرف از طریق شماره تلفن (بدون لینک دعوت).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>verify-party-otp/:id</code></td><td>تأیید OTP یک طرف و اتصال حساب آنها.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>blame-confession/:id</code></td><td>ثبت اعتراف تقصیر طرف.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[فقط CAR_BODY] فرم نوع تصادف.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>فرم اولیه / استعلام پلاک یا VIN برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:id</code></td><td>بارگذاری ویدیوی طرف اول.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code></td><td>افزودن موقعیت GPS برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-voice/:id</code></td><td>بارگذاری ضبط صوتی برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-description/:id</code></td><td>افزودن توضیحات برای طرف فعلی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-second-party/:phone/:id/</code></td><td>پیشروی به طرف دوم (بدون ارسال لینک SMS).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>بارگذاری امضای طرف (اول سپس دوم، پارامتر partyRole).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>ذخیره فیلدهای تصادف و تکمیل فوری تقصیر (بدون صف کارشناس).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>ثبت تقصیر + خسارت V3 — <code>v3/expert-initiated/blame-request-management/</code></h3>
<p class="note">ترتیب مراحل بازسازمان‌دهی‌شده: ابتدا تمام روایت طرفین، سپس ارزیابی خسارت. هم تقصیر هم خسارت در این کنترلر واحد مدیریت می‌شوند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code> ← <code>send-party-otp</code> ← <code>verify-party-otp</code> ← <code>run-inquiries</code> ← <code>add-detail-*</code> ← <code>sign</code> (×۲)</td><td>مرحله روایت طرفین (مراحل ۱–۸) — اندپوینت‌های یکسان با mirror، همان قرارداد.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>مرحله ۹: ذخیره فیلدهای تصادف پس از امضای هر دو طرف.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>مرحله ۱۰: دریافت شناسه خسارت ایجادشده به‌صورت خودکار.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>مرحله ۱۱: بارگذاری اسناد گواهینامه / کارت خودرو.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code> / <code>select-other-parts/:claimId</code></td><td>مراحل ۱۲–۱۳: انتخاب قطعات آسیب‌دیده.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>مرحله ۱۴: عکس‌برداری از قطعات + زوایا.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>مرحله ۱۵: ویدیوی دور زدن خودرو.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td>مرحله ۱۶: ویدیوی تصادف تقصیر (نهایی) ← WAITING_FOR_EXPERT (THIRD_PARTY) یا COMPLETED (CAR_BODY).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>دسترسی به پنل expert-blame + expert-claim (خواندن + اقدام روی فایل‌های خود)</h3>
<p class="note">
FIELD_EXPERT مسیر <code>v2/expert-blame/</code> را محدود به فایل‌های ساخته‌شده توسط خودش می‌بیند (نه صف اختلاف).
همچنین <code>v2/expert-claim/</code> را برای خسارت‌های مرتبط با فایل‌های تقصیرش می‌بیند.
اندپوینت‌های یکسان با پنل‌های کارشناس تقصیر و کارشناس خسارت در بالا.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-maker">۵ — فایل‌ساز <span class="role-enum">file_maker</span></h2>
<p class="section-intro">
اولین اکتور در تقسیم V4/V5. فایل‌ساز روایت طرفین را در محل انجام می‌دهد:
OTPها، استعلام‌ها، موقعیت/توضیحات/صدا و امضاها برای هر دو طرف.
همچنین اسناد اولیه خسارت (گواهینامه‌ها، کارت‌های خودرو) را بارگذاری می‌کند. پس از
امضای دوم، فایل برای تحویل به بازبین فایل «مهرومومه» می‌شود. در V5، فایل‌ساز
در انتها بازمی‌گردد تا خسارت تکمیل‌شده را تأیید یا رد کند. پس از تأیید،
کارشناس پرونده را به‌صورت دستی به فناوران ارسال می‌کند.
</p>
<div class="card card-purple">
<h3>ثبت تقصیر — <code>v4/file-maker/blame-request-management/</code> و <code>v5/…</code></h3>
<p class="note">اندپوینت‌های V4 و V5 یکسان هستند — فقط پیشوند تغییر می‌کند. V5 هنگام ایجاد <code>requiresFileMakerApproval=true</code> را تنظیم می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>ایجاد فایل تقصیر IN_PERSON.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست تمام فایل‌های تقصیر ایجادشده توسط این فایل‌ساز.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>جزئیات کامل یک فایل (طرفین، گردش کار، شناسه خسارت مرتبط).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>دریافت شناسه خسارت ایجادشده به‌صورت خودکار پس از استعلام طرف مقصر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code> / <code>verify-party-otp/:id</code></td><td>ارسال + تأیید OTP برای یک طرف در هر بار (ابتدا مقصر، سپس زیان‌دیده).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[فقط CAR_BODY] فرم نوع تصادف.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>اجرای استعلام پلاک یا VIN. فراخوانی اول = مقصر (+ خودکار خسارت ایجاد می‌کند). فراخوانی دوم = زیان‌دیده (فقط THIRD_PARTY).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>افزودن موقعیت، توضیحات و صدا برای طرف فعلی (پارامتر partyRole، FIRST/SECOND را انتخاب می‌کند).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>بارگذاری امضای طرف (partyRole=FIRST سپس SECOND). پس از امضای دوم، فایل مهرومومه می‌شود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>بارگذاری گواهینامه / کارت‌های خودرو روی خسارت ایجادشده به‌صورت خودکار.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>الزامات عکس‌برداری آگاه از مرحله (فازها: اسناد پیش از عکس‌برداری در مقابل قطعات آسیب‌دیده + شاسی/موتور).</td></tr>
</table>
</div>
<div class="card card-purple">
<h3>تأیید خسارت V5 — <code>v5/file-maker/claim-approval/</code></h3>
<p class="note">فقط در V5 استفاده می‌شود. پس از بررسی کارشناس خسارت و اعتبارسنجی فاکتورهای لازم، خسارت وارد <code>WAITING_FOR_FILE_MAKER_APPROVAL</code> می‌شود؛ امضای نهایی مالک لازم نیست.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>تأیید خسارت تکمیل‌شده ← وضعیت خسارت <code>COMPLETED</code> می‌شود. کارشناس در زمان مناسب آن را به‌صورت دستی به فناوران ارسال می‌کند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>رد به بازبین فایل ← خسارت به WAITING_FOR_DAMAGE_EXPERT برمی‌گردد. محدودیت: حداکثر ۲ رد در هر خسارت؛ تلاش سوم ۴۲۲ با کد <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code> برمی‌گرداند.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-reviewer">۶ — بازبین فایل <span class="role-enum">file_reviewer</span></h2>
<p class="section-intro">
دومین اکتور در تقسیم V4/V5. بازبین فایل فایل‌های مهرومومه‌شده (پس از اتمام کار
فایل‌ساز) را تحویل می‌گیرد و مرحله کامل ارزیابی خسارت را انجام می‌دهد: فیلدهای
تصادف، دریافت الزامات عکس‌برداری، بارگذاری اسناد (شاسی/موتور)، انتخاب قطعات،
عکس‌های قطعات و ویدیوی دور زدن خودرو. تقصیر با car-capture به
COMPLETED علامت‌گذاری می‌شود. بازبین فایل همچنین دسترسی خواندن به پنل expert-claim
برای خسارت‌هایی که بررسی می‌کند دارد.
</p>
<div class="card card-teal">
<h3>ارزیابی خسارت — <code>v4/file-reviewer/blame-request-management/</code> و <code>v5/…</code></h3>
<p class="note">اندپوینت‌های V4 و V5 یکسان هستند — فقط پیشوند تغییر می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست فایل‌های مهروموم‌شدهٔ فایل‌ساز که در شرکت بیمهٔ این بازبین برای دریافت آماده‌اند، به‌علاوهٔ فایل‌های تخصیص‌یافته به خود او.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>جزئیات کامل یک فایل آماده برای دریافت یا تخصیص‌یافته، در شرکت بیمهٔ همین بازبین (طرفین، گردش کار، فیلدهای کارشناس، شناسه خسارت مرتبط).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>دریافت شناسه خسارت ایجادشده به‌صورت خودکار (از استعلام طرف مقصر فایل‌ساز).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>مرحله ۱ (بازبین): ذخیره فیلدهای تصادف (accidentWay، accidentReason، accidentType).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>الزامات عکس‌برداری آگاه از مرحله (فاز اسناد پیش از عکس‌برداری در مقابل فاز عکس‌برداری قطعات).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>بارگذاری اسناد شاسی / موتور / پلاک فلزی.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>انتخاب قطعات آسیب‌دیده بیرونی (بدنه).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>انتخاب سایر قطعات آسیب‌دیده (غیر بدنه).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>عکس‌برداری از قطعات + زوایا برای هر قطعه آسیب‌دیده انتخاب‌شده.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>ویدیوی دور زدن خودرو (آخرین مرحله عکس‌برداری بازبین). خسارت ← <code>WAITING_FOR_DAMAGE_EXPERT</code>، تقصیر ← <code>COMPLETED</code>.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>فقط برای خسارت‌های ترکیبیِ قیمت/فاکتور: ثبت موافقت با خطوط قیمت‌گذاری‌شده پیش از بارگذاری فاکتور. امضای نهایی مالک دیگر لازم نیست.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">در V4/V5 بی‌اثر است — تقصیر از قبل توسط car-capture COMPLETED شده. موفقیت idempotent برمی‌گرداند.</td></tr>
</table>
</div>
<div class="card card-teal">
<h3>دسترسی به پنل expert-claim</h3>
<p class="note">
FILE_REVIEWER در نقش‌های مجاز برای <code>v2/expert-claim/</code> است.
می‌تواند جزئیات خسارت را مشاهده کند و جریان assign/lock را برای خسارت‌های
مرتبط با فایل‌هایش اجرا کند. نمی‌تواند به‌طور مستقل درخواست ارسال مجدد
کارشناس خسارت را آغاز کند.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="registrar">۷ — ثبات <span class="role-enum">registrar</span></h2>
<p class="section-intro">
نقش اداری که تقصیر و خسارت حضوری را به نمایندگی از طرفین ثبت می‌کند.
از جریان OTP دسته‌ای استفاده می‌کند (OTPهای هر دو طرف به‌صورت همزمان ارسال و
تأیید می‌شوند) به جای OTP یک‌به‌یک که توسط کارشناسان میدانی استفاده می‌شود.
پس از تقصیر، ثبات آینه API خسارت کاربر را دنبال می‌کند تا انتخاب قطعات، اسناد
و عکس‌برداری را پر کند. سپس فایل وارد چرخه عادی بررسی کارشناس خسارت می‌شود.
</p>
<div class="card card-gray">
<h3>ثبت تقصیر — <code>registrar-initiated-blame/</code></h3>
<p class="note">توجه: <code>@ApiExcludeController</code> — مسیرها وجود دارند اما در مستندات Swagger نمایش داده نمی‌شوند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/create</code></td><td>ایجاد فایل تقصیر IN_PERSON.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/my-files</code></td><td>فهرست تمام فایل‌های تقصیر ایجادشده توسط این ثبات.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/blame/:requestId</code></td><td>جزئیات کامل یک فایل تقصیر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/send-party-otps/:id</code></td><td>ارسال OTP به هر دو طرف به‌صورت همزمان.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/verify-party-otps/:id</code></td><td>تأیید OTPهای هر دو طرف در یک فراخوانی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/complete-blame-data/:id</code></td><td>ارسال تمام داده‌های فرم تقصیر هر دو طرف در یک پیلود.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-video/:id</code></td><td>بارگذاری ویدیوی تقصیر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-voice/:id</code></td><td>بارگذاری ضبط صوتی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/add-accident-fields/:id</code></td><td>ذخیره فیلدهای تصادف و تکمیل تقصیر.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-party-signature/:id</code></td><td>بارگذاری امضای یک طرف (partyRole=FIRST/SECOND).</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>ثبت خسارت — <code>v2/registrar/claim-request-management/</code></h3>
<p class="note">آینه‌ای از API خسارت کاربر. فرانت‌اند همان صفحات خسارت را با تغییر فقط پیشوند بازاستفاده می‌کند.</p>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>ایجاد خسارت از یک فایل تقصیر تکمیل‌شده.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>کاتالوگ قطعات (قطعات بیرونی بدنه + JSON سایر قطعات).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>فهرست شعب بیمه‌گر (برای انتخاب شعبه در مرحله امضای خسارت).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>انتخاب قطعات آسیب‌دیده بیرونی.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>انتخاب سایر قطعات آسیب‌دیده + اطلاعات بانکی.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>بارگذاری اسناد خسارت (گواهینامه، کارت خودرو).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>عکس‌برداری از قطعات + زوایا.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>ویدیوی دور زدن خودرو (مرحله نهایی) ← WAITING_FOR_DAMAGE_EXPERT.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="call-center">۸ — مرکز تماس <span class="role-enum">call_center</span></h2>
<p class="section-intro">
ثبت تقصیر تلفنی V6 را مدیریت می‌کند. اپراتور داده‌های طرف مقصر را از طریق تلفن
جمع‌آوری می‌کند، استعلام بیمه را اجرا می‌کند و لینک تقصیر را از طریق SMS ارسال
می‌کند. کاربر سپس بقیه فرم را از طریق جریان استاندارد V2 تکمیل می‌کند
(با رد شدن مرحله فرم اولیه/استعلام). کار اپراتور مرکز تماس پس از send-link
پایان می‌یابد؛ می‌تواند پیشرفت را از طریق اندپوینت‌های خواندن پایش کند.
</p>
<div class="card card-indigo">
<h3>اندپوینت‌ها — <code>v6/call-center-blame/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create</code></td><td>ایجاد فایل تقصیر LINK. بدنه: <code>{ type: "THIRD_PARTY" | "CAR_BODY" }</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry/:requestId</code></td><td>اجرای استعلام بیمه پلاک + کد ملی برای طرف مقصر. نتیجه را روی سند تقصیر ذخیره می‌کند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry-vin/:requestId</code></td><td>VIN/شاسی جایگزین برای run-inquiry. از جستجوی شاسی ESG استفاده می‌کند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-link/:requestId</code></td><td>در صورت لزوم کاربر را ثبت‌نام می‌کند، به‌عنوان طرف اول ذخیره می‌کند، لینک دعوت تقصیر را از طریق SMS ارسال می‌کند. بدنه: <code>{ phoneNumber }</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>فهرست تمام فایل‌های تقصیر شروع‌شده توسط این اپراتور.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>blame/:requestId</code></td><td>وضعیت فعلی و مرحله گردش کار یک فایل (برای بررسی اینکه کاربر لینک را باز کرده و پیشرفت کرده است).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
پس از <code>send-link</code>، کاربر فرم را از طریق
<code>v2/blame-request-management/</code> (جریان استاندارد V2) تکمیل می‌کند.
مرحله فرم اولیه / استعلام به‌صورت خودکار رد می‌شود
(<code>skipInitialFormStep=true</code>). جریان خسارت پایین‌دستی همان
جریان استاندارد خسارت V2 است.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2>مشترک: احراز هویت اکتورها</h2>
<p class="section-intro">
تمام اکتورهای پنل (هر نقش به جز <code>user</code>) از طریق همان اندپوینت
<code>POST actor/login</code> با کپچا احراز هویت می‌کنند. بازنشانی رمز عبور از
طریق OTP ایمیل است. خواندن و ویرایش پروفایل نیز مشترک است.
</p>
<div class="card card-gray">
<h3>اندپوینت‌ها — <code>actor/</code></h3>
<table>
<tr><th style="width:70px">متد</th><th>مسیر</th><th>توضیح</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/captcha</code></td><td>صدور یک چالش کپچای ورود جدید (captchaId + تصویر SVG را برمی‌گرداند).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/login</code></td><td>احراز هویت هر نقش اکتور. بدنه: role، username/email/nationalCode، password، captchaId، captcha. توکن‌های JWT دسترسی + رفرش را برمی‌گرداند.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password</code></td><td>ارسال OTP بازنشانی رمز عبور به ایمیل.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password-verify</code></td><td>تأیید OTP و تنظیم رمز عبور جدید.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/profile</code></td><td>دریافت پروفایل اکتور فعلی.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>actor/profile</code></td><td>به‌روزرسانی پروفایل اکتور فعلی.</td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,617 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Panel Roles Reference</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.6;
background: #ffffff;
color: #1f2328;
padding: 24px;
}
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: #57606a; margin-bottom: 28px; }
h2 {
font-size: 15px; font-weight: 700;
margin-bottom: 10px; margin-top: 32px;
border-bottom: 1px solid #e5e7eb; padding-bottom: 6px;
}
h3 {
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
color: #57606a; margin-bottom: 8px; margin-top: 14px;
}
.section-intro {
font-size: 13px; color: #57606a;
margin-bottom: 14px; line-height: 1.5;
}
/* Role header strip */
.role-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
}
.role-name {
font-size: 15px;
font-weight: 700;
}
.role-enum {
font-family: monospace;
font-size: 11px;
color: #3b82d4;
background: #f0f7ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
padding: 1px 6px;
}
.badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 1px 7px; border-radius: 10px;
margin-right: 4px; margin-bottom: 3px;
}
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-purple { background: #ede9fe; color: #5b21b6; }
.badge-orange { background: #ffedd5; color: #9a3412; }
.badge-teal { background: #ccfbf1; color: #0f766e; }
.badge-indigo { background: #e0e7ff; color: #3730a3; }
.badge-gray { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.badge-red { background: #fee2e2; color: #991b1b; }
/* Cards */
.card {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 16px;
background: #f7f8fa;
margin-bottom: 16px;
}
.card.card-blue { border-left: 4px solid #3b82f6; }
.card.card-green { border-left: 4px solid #22c55e; }
.card.card-purple { border-left: 4px solid #8b5cf6; }
.card.card-orange { border-left: 4px solid #f97316; }
.card.card-teal { border-left: 4px solid #14b8a6; }
.card.card-indigo { border-left: 4px solid #6366f1; }
.card.card-gray { border-left: 4px solid #94a3b8; }
/* Endpoint tables */
table {
border-collapse: collapse;
width: 100%;
font-size: 12px;
margin-top: 4px;
}
th {
background: #f1f5f9; font-weight: 600;
text-align: left; padding: 5px 8px;
border: 1px solid #e5e7eb;
}
td {
padding: 4px 8px; border: 1px solid #e5e7eb;
vertical-align: top;
}
tr:nth-child(even) td { background: #ffffff; }
code {
font-family: monospace; font-size: 11px; color: #3b82d4;
}
.method {
font-family: monospace; font-size: 11px;
font-weight: 700; white-space: nowrap;
}
.method.get { color: #059669; }
.method.post { color: #2563eb; }
.method.put { color: #d97706; }
.method.patch { color: #7c3aed; }
.method.delete { color: #dc2626; }
.dep { color: #94a3b8; font-style: italic; font-size: 11px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 860px) { .grid-2 { grid-template-columns: 1fr; } }
.note {
font-size: 11px; color: #57606a; font-style: italic;
margin-top: 6px;
}
.toc {
background: #f7f8fa; border: 1px solid #e5e7eb;
border-radius: 6px; padding: 14px 18px;
margin-bottom: 28px;
}
.toc-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
.toc ol { padding-left: 18px; }
.toc li { font-size: 13px; margin-bottom: 3px; }
.toc a { color: #3b82d4; text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer {
text-align: center; font-size: 12px; color: #57606a;
border-top: 1px solid #e5e7eb;
margin-top: 40px; padding-top: 12px;
}
.max-wrap { max-width: 760px; margin: 0 auto; }
/* Role overview table */
.overview-table { font-size: 12px; margin-bottom: 24px; }
.overview-table th { white-space: nowrap; }
.overview-table td:first-child { font-weight: 600; white-space: nowrap; }
</style>
</head>
<body>
<div class="max-wrap">
<h1>Panel Roles Reference</h1>
<p class="subtitle">
What every actor role can see and do — endpoints, responsibilities, and
process steps. Super-admin excluded.
</p>
<!-- Table of Contents -->
<div class="toc">
<div class="toc-title">Roles covered</div>
<ol>
<li><a href="#insurer">Insurer (COMPANY) — the insurance-company tenant admin</a></li>
<li><a href="#blame-expert">Blame Expert (EXPERT) — disagreement review queue</a></li>
<li><a href="#damage-expert">Damage Expert (DAMAGE_EXPERT) — claim pricing</a></li>
<li><a href="#field-expert">Field Expert (FIELD_EXPERT) — on-scene in-person filing</a></li>
<li><a href="#file-maker">File Maker (FILE_MAKER) — V4/V5 party narrative</a></li>
<li><a href="#file-reviewer">File Reviewer (FILE_REVIEWER) — V4/V5 damage assessment</a></li>
<li><a href="#registrar">Registrar (REGISTRAR) — office-based filing</a></li>
<li><a href="#call-center">Call Center (CALL_CENTER) — V6 phone-initiated filing</a></li>
</ol>
</div>
<!-- ── Role overview table ────────────────────────────────────── -->
<h2>Role Overview</h2>
<table class="overview-table">
<tr>
<th>Role enum</th>
<th>Login panel</th>
<th>Scope</th>
<th>Primary job</th>
</tr>
<tr>
<td><code>company</code></td>
<td>Insurer portal</td>
<td>Tenant-wide</td>
<td>View all files; manage branches, experts; run reports; rate experts</td>
</tr>
<tr>
<td><code>expert</code></td>
<td>Blame expert panel</td>
<td>Tenant DISAGREEMENT queue</td>
<td>Lock blame cases, review party submissions, submit verdict or request resend</td>
</tr>
<tr>
<td><code>damage_expert</code></td>
<td>Claim/damage panel</td>
<td>Tenant claim queue</td>
<td>Lock claims, price damage, validate repair factors, request resend/visit</td>
</tr>
<tr>
<td><code>field_expert</code></td>
<td>Field expert panel</td>
<td>Own created files</td>
<td>V2/V3 in-person blame + claim filing; also sees blame/claim review panels</td>
</tr>
<tr>
<td><code>file_maker</code></td>
<td>FileMaker panel</td>
<td>Own created files</td>
<td>V4/V5 party narrative (OTPs, inquiries, details, signatures); V5 claim approval</td>
</tr>
<tr>
<td><code>file_reviewer</code></td>
<td>FileReviewer panel</td>
<td>Assigned files</td>
<td>V4/V5 damage assessment (accident fields, parts, captures; mixed-factor priced-line acceptance when needed)</td>
</tr>
<tr>
<td><code>registrar</code></td>
<td>Registrar panel</td>
<td>Own created files</td>
<td>Office-based in-person blame + claim filing on behalf of parties</td>
</tr>
<tr>
<td><code>call_center</code></td>
<td>Call-center panel</td>
<td>Own created files</td>
<td>V6 phone-initiated blame: run inquiry, send link; user completes the rest</td>
</tr>
</table>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="insurer">1 — Insurer <span class="role-enum">company</span></h2>
<p class="section-intro">
One <code>company</code> actor per insurance-company tenant. The insurer portal is the
management layer: it can see everything under its tenant, manage the expert roster,
manage branches, configure per-tenant media settings, and pull statistical reports.
The insurer never touches blame/claim steps directly — it only observes and rates.
</p>
<div class="card card-blue">
<h3>File management — <code>expert-insurer/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files</code></td><td>List all blame + claim files for the tenant (merged by publicId). Filterable by status, file type, search, sort, page.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId</code></td><td>Full detail for one file by publicId.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/timeline</code></td><td>Chronological activity timeline (all history events: source, type, actor, metadata).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/files/:publicId/report</code></td><td>Structured report data for PDF generation (owner, driver, insurance, vehicle, accident sections).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/files/:publicId/rating</code></td><td>Rate the experts on a file (1–5 per dimension: collision method, timeliness, cause accuracy, guilty-ID accuracy, bot rating).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/unified-file-statuses</code></td><td>Unified status catalog + per-status counts for the whole tenant portfolio. Filterable by fileType and date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/report/status-counts</code></td><td class="dep">Deprecated — prefer unified-file-statuses.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Branch management — <code>expert-insurer/branches</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/branches</code></td><td>List all branches for this insurer. Query: search, from/to date, isActive filter.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/branches</code></td><td>Add a new branch (name, code, address, city, phone, etc.).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>expert-insurer/branches/:branchId/status</code></td><td>Activate or deactivate a branch.</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Expert roster management — <code>expert-insurer/experts</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/blame</code></td><td>Create a new blame-expert account under this insurer.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/claim</code></td><td>Create a new damage-expert (claim) account under this insurer.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-maker</code></td><td>Create a new FileMaker account under this insurer.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>expert-insurer/experts/file-reviewer</code></td><td>Create a new FileReviewer account under this insurer.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/list</code></td><td>Paginated list of all expert accounts on this tenant.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/experts/top</code></td><td>Top blame vs claim experts ranked by overall average rating (up to 10 each).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-experts</code></td><td>Alias for experts/top (frontend compat).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/:expertId</code></td><td>Files handled by one expert (slim summary rows — blame or claim depending on expert type).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Statistics &amp; reports</h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/statistics</code></td><td>KPI cards: totalFilesReviewed, averageUserRating, inPersonCount, filesThisMonth, objectionPercentage, etc. Filterable by date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/top-files</code></td><td>Top 10 highest-rated claim files (combined insurer + user score).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>expert-insurer/expert-work-log</code></td><td>Per-expert work log: totalHandled, currentlyChecking, distinctFilesCheckedInPeriod. Filterable by expertKind and date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/requests</code></td><td>Claim + blame status bucket counts + unified file count for the tenant.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/per-month-requests</code></td><td>Same summary, broken down by the last 5 calendar months.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/checked-requests</code></td><td>Same summary filtered by optional createdAt date range.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log</code></td><td>Expert work log (blame + damage expert collections, not field experts).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>reports/report/insurer/expert-work-log/per-month</code></td><td>Same work log per calendar month (last 5).</td></tr>
</table>
</div>
<div class="card card-blue">
<h3>Tenant settings — <code>client-panel/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>client-panel/settings</code></td><td>Get per-tenant media limits (video/image/voice maxBytes) and CAR_BODY accident window (days).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>client-panel/settings</code></td><td>Update those settings (partial). Cannot exceed system-level route ceilings.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="blame-expert">2 — Blame Expert <span class="role-enum">expert</span></h2>
<p class="section-intro">
Reviews blame files in the DISAGREEMENT queue — cases where the two parties do not
agree on who is at fault. After reviewing submitted documents and party statements
the expert locks the case, then either submits a verdict, asks the parties to resend
documents, or records an in-person visit outcome. All endpoints are under
<code>v2/expert-blame/</code>.
</p>
<div class="card card-orange">
<h3>Process</h3>
<p class="note">
1 Browse list → 2 Assign (lock) the case → 3 Review party evidence (videos, voices, documents) →
4a Submit verdict <em>or</em> 4b Request document resend <em>or</em> 4c Record in-person visit.
</p>
</div>
<div class="card card-orange">
<h3>Endpoints — <code>v2/expert-blame/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/</code></td><td>List blame cases in the DISAGREEMENT queue (available, locked by me, or decided by me). Query: search, sortBy, sortOrder, page, limit, unifiedStatus, fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/:id</code></td><td>Full detail for one blame case (party statements, photos, voices, videos).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-blame/:id/assign</code></td><td>Check availability and lock the case to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409 if someone else holds it.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/submit/:id</code></td><td>Submit verdict (accidentWay, accidentReason, accidentType, guilty party decision). Unlocks the case and moves it to COMPLETED.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/resend/:id</code></td><td>Request parties to re-upload documents. Sets blame to WAITING_FOR_RESEND. One resend request per lifecycle.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/reply/inPerson/:id</code></td><td>Record that an in-person visit was made and submit verdict.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/unified-file-statuses</code></td><td>Status catalog + per-status counts for this expert's portfolio.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-blame/report/status-counts</code></td><td class="dep">Deprecated — prefer unified-file-statuses.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-blame/lock/:id</code></td><td class="dep">Deprecated lock endpoint — use POST assign.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="damage-expert">3 — Damage Expert <span class="role-enum">damage_expert</span></h2>
<p class="section-intro">
Reviews claim files after the user has submitted their damage evidence. The expert
prices each damaged part, optionally calculates a price-drop (depreciation), and
can ask the user to resend documents, come in person, or upload repair factor invoices
when workshop pricing is needed. All endpoints are under <code>v2/expert-claim/</code>.
</p>
<div class="card card-red">
<h3>Process</h3>
<p class="note">
1 Browse list → 2 Assign (lock) the claim → 3 Review damage photos and documents →
4 Optionally edit selected parts or calculate price-drop →
5a Submit priced reply <em>or</em> 5b Request resend <em>or</em> 5c Request in-person visit →
6 If factor parts present: validate uploaded factor invoices.
</p>
</div>
<div class="card card-red">
<h3>Endpoints — <code>v2/expert-claim/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/requests</code></td><td>List claims in <code>WAITING_FOR_DAMAGE_EXPERT</code> queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId</code></td><td>Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>v2/expert-claim/assign/:claimRequestId</code></td><td>Lock claim to this expert. Returns <code>assigned</code>, <code>already_assigned_to_you</code>, or 409.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/request/:claimRequestId/price-drop</code></td><td>Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/submit/:claimRequestId</code></td><td>Submit damage assessment reply (priced parts list, daghi, branchId). Cap: total ≤ 53 000 000 Toman. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/reply/resend/:claimRequestId</code></td><td>Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/:claimRequestId/visit</code></td><td>Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/validate-factors/:claimRequestId</code></td><td>Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>v2/expert-claim/request/:claimRequestId/damaged-parts</code></td><td>Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/outer-parts-catalog</code></td><td>Fanavaran outer car-components catalog (shared with user flow).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/inner-parts-catalog</code></td><td>Static inner car-parts catalog JSON.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/branches</code></td><td>Insurer branches for this expert's tenant (for daghi/branch selection in reply payload).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/stream/:id/video</code></td><td>Stream claim video (car-capture walk-around or accident video). Query: <code>query=car-capture|accident</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/unified-file-statuses</code></td><td>Status catalog + counts for this expert's claim portfolio.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>v2/expert-claim/report/status-counts</code></td><td class="dep">Deprecated — prefer unified-file-statuses.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>v2/expert-claim/lock/:claimRequestId</code></td><td class="dep">Deprecated lock endpoint — use POST assign.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="field-expert">4 — Field Expert <span class="role-enum">field_expert</span></h2>
<p class="section-intro">
Goes to the accident scene and fills both parties' forms in-person (V2 mirror / V3 flows).
The field expert also has read access to the expert-blame and expert-claim panels
(scoped to their own files). They are the only role that spans both
<strong>blame filing</strong> and <strong>claim filing</strong> in the same session.
</p>
<div class="card card-green">
<h3>Blame filing — <code>v2/expert-initiated/blame-request-management/</code></h3>
<p class="note">Mirror of the user blame API. Frontend reuses same pages by swapping prefix only.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>Create IN_PERSON blame file.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code></td><td>Send OTP to one party by phone number (no invite link).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>verify-party-otp/:id</code></td><td>Verify one party's OTP and bind their account.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>blame-confession/:id</code></td><td>Record party's blame confession.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[CAR_BODY only] Accident type form.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Initial form / plate or VIN inquiry for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:id</code></td><td>Upload first-party video.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code></td><td>Add GPS location for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-voice/:id</code></td><td>Upload voice recording for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-description/:id</code></td><td>Add description for current party.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-second-party/:phone/:id/</code></td><td>Advance to second party (no SMS link sent).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (FIRST then SECOND, partyRole param).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>Save accident fields and complete blame immediately (no expert queue).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>V3 blame + claim filing — <code>v3/expert-initiated/blame-request-management/</code></h3>
<p class="note">Reorganised step order: all party narrative first, then damage assessment. Blame and claim both handled in this single controller.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code> → <code>send-party-otp</code> → <code>verify-party-otp</code> → <code>run-inquiries</code> → <code>add-detail-*</code> → <code>sign</code> (×2)</td><td>Party narrative phase (steps 1–8) — identical endpoints to mirror, same contract.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:id</code></td><td>Step 9: save accident fields after both parties have signed.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Step 10: get auto-created claim ID.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Step 11: upload licence / car card documents.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code> / <code>select-other-parts/:claimId</code></td><td>Steps 12–13: select damaged parts.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Step 14: capture part photos + angles.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Step 15: walk-around video.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td>Step 16: blame accident video (final) → WAITING_FOR_EXPERT (THIRD_PARTY) or COMPLETED (CAR_BODY).</td></tr>
</table>
</div>
<div class="card card-green">
<h3>Expert-blame + expert-claim panel (read + action on own files)</h3>
<p class="note">
FIELD_EXPERT sees <code>v2/expert-blame/</code> scoped to their own created files (not the disagreement queue).
They also see <code>v2/expert-claim/</code> for claims linked to their blame files.
Same endpoints as blame-expert and damage-expert panels above.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-maker">5 — File Maker <span class="role-enum">file_maker</span></h2>
<p class="section-intro">
The first actor in the V4/V5 split. FileMaker handles the party narrative on-site:
OTPs, inquiries, location/description/voice, and signatures for both parties.
They also upload the initial claim documents (licences, car cards). After the second
signature the file is "sealed" for FileReviewer pickup. In V5, FileMaker comes
back at the end to approve or reject the completed claim. After approval, an
expert submits the case to Fanavaran manually.
</p>
<div class="card card-purple">
<h3>Blame filing — <code>v4/file-maker/blame-request-management/</code> and <code>v5/…</code></h3>
<p class="note">V4 and V5 endpoints are identical — only the prefix changes. V5 sets <code>requiresFileMakerApproval=true</code> at creation.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>POST /</code></td><td>Create IN_PERSON blame file.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List all blame files created by this FileMaker.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>Full detail for one file (parties, workflow, linked claim ID).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID after guilty-party run-inquiries.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-party-otp/:id</code> / <code>verify-party-otp/:id</code></td><td>Send + verify OTP for one party at a time (guilty first, then damaged).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>car-body-form/:id</code></td><td>[CAR_BODY only] Accident type form.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiries/:id</code> / <code>run-inquiries-vin/:id</code></td><td>Run plate or VIN inquiry. First call = guilty (+ auto-creates claim). Second call = damaged (THIRD_PARTY only).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>add-detail-location/:id</code> / <code>add-detail-description/:id</code> / <code>upload-voice/:id</code></td><td>Add location, description, and voice for current party (partyRole param selects FIRST/SECOND).</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>sign/:id</code></td><td>Upload party signature (partyRole=FIRST then SECOND). After second signature, file is sealed.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload licences / car cards against the auto-created claim.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (phases: pre-capture docs vs damaged parts + chassis/engine).</td></tr>
</table>
</div>
<div class="card card-purple">
<h3>V5 claim approval — <code>v5/file-maker/claim-approval/</code></h3>
<p class="note">Used only in V5. After damage expert review and any required factor validation, claim enters <code>WAITING_FOR_FILE_MAKER_APPROVAL</code>; no final owner signature is needed.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>approve/:claimId</code></td><td>Approve the completed claim → claim becomes <code>COMPLETED</code>. An expert submits to Fanavaran manually when ready.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>reject/:claimId</code></td><td>Reject back to FileReviewer → claim returns to WAITING_FOR_DAMAGE_EXPERT. Limit: max 2 rejections per claim; 3rd attempt returns 422 <code>FILE_MAKER_REJECTION_LIMIT_EXCEEDED</code>.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="file-reviewer">6 — File Reviewer <span class="role-enum">file_reviewer</span></h2>
<p class="section-intro">
The second actor in the V4/V5 split. FileReviewer picks up sealed files (after
FileMaker is done) and performs the full damage assessment pass: accident fields,
capture requirements lookup, document upload (chassis/engine), part selection,
part photos and walk-around video. The blame is marked COMPLETED
by car-capture. FileReviewer also has read access to the expert-claim panel for
claims they are reviewing.
</p>
<div class="card card-teal">
<h3>Damage assessment — <code>v4/file-reviewer/blame-request-management/</code> and <code>v5/…</code></h3>
<p class="note">V4 and V5 endpoints are identical — only the prefix changes.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List FileMaker-sealed files available to claim in this reviewer’s insurer, plus files already assigned to this FileReviewer.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files/:requestId</code></td><td>Full detail for one available or assigned file in this reviewer’s insurer (parties, workflow, expert fields, linked claim ID).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>claim-id/:requestId</code></td><td>Get the auto-created claim ID (from FileMaker's guilty-party inquiry).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>accident-fields/:requestId</code></td><td>Step 1 (FileReviewer): save accident fields (accidentWay, accidentReason, accidentType).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>capture-requirements/:claimId</code></td><td>Step-aware capture requirements (pre-capture docs phase vs capture-parts phase).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload chassis / engine / metal-plate documents.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer (body) damaged parts.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other (non-body) damaged parts.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles for each selected damaged part.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Walk-around video (final FileReviewer capture step). Claim → <code>WAITING_FOR_DAMAGE_EXPERT</code>, blame → <code>COMPLETED</code>.</td></tr>
<tr><td><span class="method put">PUT</span></td><td><code>claim-sign/:claimId</code></td><td>For mixed priced/factor claims only: record acceptance of priced lines before factor uploads. A final owner signature is no longer required.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-video/:requestId</code></td><td class="dep">No-op in V4/V5 — blame already COMPLETED by car-capture. Returns idempotent success.</td></tr>
</table>
</div>
<div class="card card-teal">
<h3>Expert-claim panel access</h3>
<p class="note">
FILE_REVIEWER is in the allowed roles for <code>v2/expert-claim/</code>.
They can view claim details and run the assign/lock flow for claims associated
with their files. They cannot initiate a damage-expert resend independently.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="registrar">7 — Registrar <span class="role-enum">registrar</span></h2>
<p class="section-intro">
Office-based role that files in-person blame and claim on behalf of parties.
Uses a bulk-OTP flow (both parties' OTPs sent and verified in one call each)
rather than the one-at-a-time OTP used by field experts. After blame, the
registrar mirrors the user claim API to fill part selection, documents, and
captures. The file then enters the normal damage-expert review lifecycle.
</p>
<div class="card card-gray">
<h3>Blame filing — <code>registrar-initiated-blame/</code></h3>
<p class="note">Note: <code>@ApiExcludeController</code> — routes exist but not surfaced in Swagger docs.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/create</code></td><td>Create IN_PERSON blame file.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/my-files</code></td><td>List all blame files created by this registrar.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>registrar-initiated-blame/blame/:requestId</code></td><td>Full detail for one blame file.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/send-party-otps/:id</code></td><td>Send OTPs to both parties simultaneously.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/verify-party-otps/:id</code></td><td>Verify both parties' OTPs in one call.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/complete-blame-data/:id</code></td><td>Submit all blame form data for both parties in one payload.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-video/:id</code></td><td>Upload blame video.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-voice/:id</code></td><td>Upload voice recording.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/add-accident-fields/:id</code></td><td>Save accident fields and complete blame.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>registrar-initiated-blame/upload-party-signature/:id</code></td><td>Upload a party's signature (partyRole=FIRST/SECOND).</td></tr>
</table>
</div>
<div class="card card-gray">
<h3>Claim filing — <code>v2/registrar/claim-request-management/</code></h3>
<p class="note">Mirror of the user claim API. Frontend reuses same claim pages by swapping prefix only.</p>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create-from-blame/:blameId</code></td><td>Create claim from a completed blame file.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>outer-parts-catalog</code> / <code>car-other-part</code></td><td>Parts catalogs (outer body parts + other parts JSON).</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>branches/:insuranceId</code></td><td>Insurer branch list (for branch selection in claim sign step).</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-outer-parts/:claimId</code></td><td>Select outer damaged parts.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>select-other-parts/:claimId</code></td><td>Select other damaged parts + bank info.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>upload-document/:claimId</code></td><td>Upload claim documents (licences, car card).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>capture-part/:claimId</code></td><td>Capture part photos + angles.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>car-capture/:claimId</code></td><td>Walk-around video (final step) → WAITING_FOR_DAMAGE_EXPERT.</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2 id="call-center">8 — Call Center <span class="role-enum">call_center</span></h2>
<p class="section-intro">
Handles V6 phone-initiated blame filing. The agent collects the guilty party's
data over the phone, runs the insurance inquiry, and sends the blame link via
SMS. The user then completes the rest of the form through the standard V2 flow
(with the initial-form/inquiry step skipped). The call-center agent's job ends
after send-link; they can monitor progress via the read endpoints.
</p>
<div class="card card-indigo">
<h3>Endpoints — <code>v6/call-center-blame/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method post">POST</span></td><td><code>create</code></td><td>Create a LINK blame file. Body: <code>{ type: "THIRD_PARTY" | "CAR_BODY" }</code>.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry/:requestId</code></td><td>Run plate + national-code insurance inquiry for the guilty party. Stores result on blame document.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>run-inquiry-vin/:requestId</code></td><td>VIN/chassis alternative to run-inquiry. Uses ESG chassis lookup.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>send-link/:requestId</code></td><td>Register user if needed, store as first party, send blame invite link via SMS. Body: <code>{ phoneNumber }</code>.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>my-files</code></td><td>List all blame files started by this agent.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>blame/:requestId</code></td><td>Current status and workflow step for one file (to check if user has opened the link and progressed).</td></tr>
</table>
<p class="note" style="margin-top:8px;">
After <code>send-link</code> the user completes the form via
<code>v2/blame-request-management/</code> (standard V2 flow).
The initial-form / inquiry step is automatically skipped
(<code>skipInitialFormStep=true</code>). Downstream claim flow is the
standard V2 claim flow.
</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<h2>Shared: Actor Authentication</h2>
<p class="section-intro">
All panel actors (every role except <code>user</code>) authenticate through the same
<code>POST actor/login</code> endpoint with captcha. Password reset is via email OTP.
Profile reads and edits are also shared.
</p>
<div class="card card-gray">
<h3>Endpoints — <code>actor/</code></h3>
<table>
<tr><th style="width:70px">Method</th><th>Route</th><th>What it does</th></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/captcha</code></td><td>Issue a new login captcha challenge (returns captchaId + SVG image).</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/login</code></td><td>Authenticate any actor role. Body: role, username/email/nationalCode, password, captchaId, captcha. Returns JWT access + refresh tokens.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password</code></td><td>Send password-reset OTP to email.</td></tr>
<tr><td><span class="method post">POST</span></td><td><code>actor/forget-password-verify</code></td><td>Verify OTP and set new password.</td></tr>
<tr><td><span class="method get">GET</span></td><td><code>actor/profile</code></td><td>Get current actor's profile.</td></tr>
<tr><td><span class="method patch">PATCH</span></td><td><code>actor/profile</code></td><td>Update current actor's profile.</td></tr>
</table>
</div>
<footer>Made by Sepehr</footer>
</div>
</body>
</html>

1422
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
"seed:reports-fixtures": "ts-node scripts/seed-insurer-reports-fixtures.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@@ -49,6 +50,7 @@
"svg-captcha": "^1.4.0"
},
"devDependencies": {
"@compodoc/compodoc": "^2.0.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",

View File

@@ -2,143 +2,13 @@
"clientCode": 8,
"branches": [
{
"code": "100100",
"name": "واحدصدورالکترونيکي",
"fullName": "واحدصدورالکترونيکي(100100)",
"code": "210120",
"name": "شعبه والفجر",
"fullName": "شعبه والفجر(210120)",
"city": "تهران",
"state": "تهران",
"address": "خيابان وليعصر_بلوار ميرداماد_پلاک 22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "110011",
"name": "ستاد مرکزي",
"fullName": "ستاد مرکزي(110011)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "111130",
"name": "شعبه ويژه ميرداماد",
"fullName": "شعبه ويژه ميرداماد(111130)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "120021",
"name": "سرپرستي منطقه يک كشور",
"fullName": "سرپرستي منطقه يک كشور(120021)",
"city": "تهران",
"state": "تهران",
"address": "تهران،خيابان وليعصر ،خيابان قباديان غربي ،پلاک 22 ، طبقه همکف",
"phoneNumber": "0218259",
"isActive": true
},
{
"code": "130031",
"name": "سرپرستي منطقه مركزي كشور",
"fullName": "سرپرستي منطقه مركزي كشور(130031)",
"city": "تهران",
"state": "تهران",
"address": "اصفهان، خيابان امام خميني (ره) - بعد از چهارراه شريف - کوچه شهيد احمدي (85)",
"phoneNumber": "03133328257",
"isActive": true
},
{
"code": "140041",
"name": "سرپرستي منطقه شمالغرب کشور",
"fullName": "سرپرستي منطقه شمالغرب کشور(140041)",
"city": "تهران",
"state": "تهران",
"address": "تبريز- خيابان ائل گلي - فلکه خيام - نبش فلکه رجائي - بيمه پارسيان",
"phoneNumber": "04133832289",
"isActive": true
},
{
"code": "150051",
"name": "سرپرستي منطقه جنوب كشور",
"fullName": "سرپرستي منطقه جنوب كشور(150051)",
"city": "تهران",
"state": "تهران",
"address": "شيراز ـ فلکه فرودگاه (ميدان بسيج) ـ ابتداي بلوار سياحتگر",
"phoneNumber": "01738315473",
"isActive": true
},
{
"code": "150053",
"name": "سرپرست منطقه جنوب شرقي کشور",
"fullName": "سرپرست منطقه جنوب شرقي کشور(150053)",
"city": "کرمان",
"state": "کرمان",
"address": "کرمان، حافظ، بعد از چهارراه جامي، پلاک 153",
"phoneNumber": "03432718000",
"isActive": true
},
{
"code": "160061",
"name": "سرپرستي منطقه شرق كشور",
"fullName": "سرپرستي منطقه شرق كشور(160061)",
"city": "تهران",
"state": "تهران",
"address": "مشهد ـ خيام شمالي ـ نبش خيام شمالي 36",
"phoneNumber": "05137659005",
"isActive": true
},
{
"code": "170071",
"name": "سرپرستي منطقه غرب كشور",
"fullName": "سرپرستي منطقه غرب كشور(170071)",
"city": "تهران",
"state": "تهران",
"address": "کرمانشاه . ميدان مرکزي خيابان خرم نبش کوي بسيج ساختمان عرفان",
"phoneNumber": "08338431017",
"isActive": true
},
{
"code": "180081",
"name": "سرپرستي منطقه شمال شرق کشور",
"fullName": "سرپرستي منطقه شمال شرق کشور(180081)",
"city": "ساري",
"state": "مازندران",
"address": "ساري، شعبه ساري",
"phoneNumber": "01133207241",
"isActive": true
},
{
"code": "180083",
"name": "سرپرست منطقه شمال کشوري",
"fullName": "سرپرست منطقه شمال کشوري(180083)",
"city": "رشت",
"state": "گيلان",
"address": "رشت، بلوار آيت اله رودباري،کدپستي:4144761893",
"phoneNumber": "01333512135",
"isActive": true
},
{
"code": "190092",
"name": "سرپرستي جنوب غربي کشور",
"fullName": "سرپرستي جنوب غربي کشور(190092)",
"city": "اهواز",
"state": "خوزستان",
"address": "اهواز،تقاطع بلوار ساحلي گلستان (خيابان فروردين)،نبش خيابان نصرت شمالي ،پلاک 701 کد پستي 6155977139",
"phoneNumber": "06133743877",
"isActive": true
},
{
"code": "210040",
"name": "شعبه شرق تهران",
"fullName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
"phoneNumber": "77393783-4",
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
"phoneNumber": "86051332",
"isActive": true
},
{
@@ -151,6 +21,26 @@
"phoneNumber": "66021968",
"isActive": true
},
{
"code": "110011",
"name": "ستاد مرکزي",
"fullName": "ستاد مرکزي(110011)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
},
{
"code": "210040",
"name": "شعبه شرق تهران",
"fullName": "شعبه شرق تهران(210040)",
"city": "تهران",
"state": "تهران",
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
"phoneNumber": "77393783-4",
"isActive": true
},
{
"code": "210110",
"name": "شعبه پونک",
@@ -162,24 +52,14 @@
"isActive": true
},
{
"code": "210120",
"name": "شعبه والفجر",
"fullName": "شعبه والفجر(210120)",
"code": "111130",
"name": "شعبه ويژه ميرداماد",
"fullName": "شعبه ويژه ميرداماد(111130)",
"city": "تهران",
"state": "تهران",
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
"phoneNumber": "86051332",
"isActive": true
},
{
"code": "210150",
"name": "شعبه شمال شرق تهران",
"fullName": "شعبه شمال شرق تهران(210150)",
"city": "تهران",
"state": "تهران",
"address": "تهران، ضلع شمال غربي ميدان بني هاشم، نبش خيابان كشوري، پلاك13",
"phoneNumber": "26244319",
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
"phoneNumber": "8259",
"isActive": true
}
]
}
}

View File

@@ -0,0 +1,464 @@
{
"clientCode": 8,
"fileMakers": [
{
"nationalCode": "0061077410",
"mobile": "09125864643",
"firstName": "احمد",
"lastName": "بستان پيرا",
"locations": [
{
"id": "210110",
"name": "شعبه پونک"
}
],
"ThirdPartyClaimExpertId": "32",
"CarBodyClaimExpertId": "33"
},
{
"nationalCode": "0492009856",
"mobile": "09128465836",
"firstName": "اسداله",
"lastName": "نجفي پور",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "91",
"CarBodyClaimExpertId": "92"
},
{
"nationalCode": "0062277553",
"mobile": "09125012274",
"firstName": "اسماعيل",
"lastName": "سلطانمحمدي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4221",
"CarBodyClaimExpertId": "4222"
},
{
"nationalCode": "0492295557",
"mobile": "09125409101",
"firstName": "اکبر",
"lastName": "بيگ محمدي",
"locations": [
{
"id": "111130",
"name": "شعبه ويژه ميرداماد"
},
{
"id": "110011",
"name": "ستاد مرکزي"
}
],
"ThirdPartyClaimExpertId": "278"
},
{
"nationalCode": "3220096573",
"mobile": "09104933206",
"firstName": "پدرام",
"lastName": "حاتمي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "219"
},
{
"nationalCode": "0078954010",
"mobile": "09128894315",
"firstName": "پروانه",
"lastName": "آدابي",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "739",
"CarBodyClaimExpertId": "740"
},
{
"nationalCode": "0075127105",
"mobile": "09122938429",
"firstName": "حميد",
"lastName": "غوثي هوجقان",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "3215",
"ThirdPartyClaimExpertId": "3214"
},
{
"nationalCode": "0081114494",
"mobile": "09374439044",
"firstName": "داود",
"lastName": "صديق",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "222",
"CarBodyClaimExpertId": "223"
},
{
"nationalCode": "0084130938",
"mobile": "09392558640",
"firstName": "رسول",
"lastName": "کرکي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4662"
},
{
"nationalCode": "0014103788",
"mobile": "09118861247",
"firstName": "سجاد",
"lastName": "حشمت",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "4245",
"CarBodyClaimExpertId": "4246"
},
{
"nationalCode": "0065938100",
"mobile": "09122366860",
"firstName": "سهيلا",
"lastName": "شکوري قره چيق",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "89",
"ThirdPartyClaimExpertId": "88"
},
{
"nationalCode": "5779888949",
"mobile": "09371290042",
"firstName": "عاطفه",
"lastName": "نصيري",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "4455",
"ThirdPartyClaimExpertId": "4454"
},
{
"nationalCode": "0069608210",
"mobile": "09120766792",
"firstName": "عباس",
"lastName": "سلطاني محمدي",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "742",
"CarBodyClaimExpertId": "743"
},
{
"nationalCode": "0065409027",
"mobile": "09125045732",
"firstName": "علي",
"lastName": "اصلاني حاجي آبادي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "4338",
"CarBodyClaimExpertId": "4339"
},
{
"nationalCode": "4570007309",
"mobile": "09104873190",
"firstName": "علي",
"lastName": "قرباني",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyClaimExpertId": "4540",
"ThirdPartyClaimExpertId": "4539"
},
{
"nationalCode": "0077396881",
"mobile": "09120000001",
"firstName": "علي",
"lastName": "مهرجو",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "4652",
"ThirdPartyClaimExpertId": "4650"
},
{
"nationalCode": "0068289782",
"mobile": "09124196488",
"firstName": "عليرضا",
"lastName": "درگاهي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "57",
"CarBodyClaimExpertId": "58"
},
{
"nationalCode": "0066868521",
"mobile": "09129344240",
"firstName": "عليرضا",
"lastName": "گودرزي پور",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4663"
},
{
"nationalCode": "0061947466",
"mobile": "09125045283",
"firstName": "غزال",
"lastName": "عطائي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyClaimExpertId": "4220",
"ThirdPartyClaimExpertId": "4219"
},
{
"nationalCode": "0062500767",
"mobile": "09124464022",
"firstName": "غلامرضا",
"lastName": "حسن پوراقدم",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyClaimExpertId": "4452",
"ThirdPartyClaimExpertId": "4451"
},
{
"nationalCode": "0019675526",
"mobile": "09120399833",
"firstName": "فاطمه",
"lastName": "عظيميان",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyClaimExpertId": "4340",
"ThirdPartyClaimExpertId": "4341"
},
{
"nationalCode": "0056888082",
"mobile": "09122406750",
"firstName": "قاسم",
"lastName": "نصراللهي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4664"
},
{
"nationalCode": "3932143930",
"mobile": "09125255267",
"firstName": "محسن",
"lastName": "کرمي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyClaimExpertId": "4415",
"ThirdPartyClaimExpertId": "4414"
},
{
"nationalCode": "0013269755",
"mobile": "09195502061",
"firstName": "محمد",
"lastName": "ابراهيمي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyClaimExpertId": "4092",
"ThirdPartyClaimExpertId": "4091"
},
{
"nationalCode": "0011834803",
"mobile": "09127059125",
"firstName": "محمد",
"lastName": "ترابي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "1010",
"CarBodyClaimExpertId": "1011"
},
{
"nationalCode": "0493217789",
"mobile": "09126966943",
"firstName": "مصطفي",
"lastName": "محمدزاده قورقچي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4665"
},
{
"nationalCode": "0080501281",
"mobile": "09356468730",
"firstName": "موسي",
"lastName": "موسي نژاد",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"CarBodyClaimExpertId": "745",
"ThirdPartyClaimExpertId": "744"
},
{
"nationalCode": "0078209129",
"mobile": "09126038117",
"firstName": "مهدي",
"lastName": "شاملوفرد",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "4666"
},
{
"nationalCode": "0074009141",
"mobile": "09339103762",
"firstName": "مهدي",
"lastName": "معمارباشي",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyClaimExpertId": "216",
"CarBodyClaimExpertId": "217"
},
{
"nationalCode": "3720211207",
"mobile": "09379120944",
"firstName": "مهسا",
"lastName": "وطن نيا",
"locations": [
{
"id": "210110",
"name": "شعبه پونک"
}
],
"ThirdPartyClaimExpertId": "34",
"CarBodyClaimExpertId": "35"
},
{
"nationalCode": "0079616801",
"mobile": "09379669839",
"firstName": "مهيار",
"lastName": "کيائي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyClaimExpertId": "1004",
"CarBodyClaimExpertId": "1005"
},
{
"nationalCode": "0079815278",
"mobile": "09195883178",
"firstName": "ميگل",
"lastName": "ميرشکاري",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "4646",
"CarBodyClaimExpertId": "4648"
},
{
"nationalCode": "0010736638",
"mobile": "09355242492",
"firstName": "ناصر",
"lastName": "عيوضي",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyClaimExpertId": "228",
"CarBodyClaimExpertId": "229"
}
]
}

View File

@@ -0,0 +1,161 @@
{
"clientCode": 8,
"fileReviewers": [
{
"nationalCode": "0051967839",
"mobile": "09121354859",
"firstName": "حسين",
"lastName": "جعفري",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyExpertiseClaim": "3542",
"CarBodyExpertiseClaim": "3541"
},
{
"nationalCode": "0084130938",
"mobile": "09392558640",
"firstName": "رسول",
"lastName": "کرکي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyExpertiseClaim": "29",
"CarBodyExpertiseClaim": "28"
},
{
"nationalCode": "1262982308",
"mobile": "09130121246",
"firstName": "روح الله",
"lastName": "سلمانيان مقدم نياسري",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"CarBodyExpertiseClaim": "3705"
},
{
"nationalCode": "0066868521",
"mobile": "09129344240",
"firstName": "عليرضا",
"lastName": "گودرزي پور",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"CarBodyExpertiseClaim": "15",
"ThirdPartyExpertiseClaim": "16"
},
{
"nationalCode": "0440245151",
"mobile": "09130606183",
"firstName": "فرهاد",
"lastName": "ملکي مونقي",
"locations": [
{
"id": "110011",
"name": "ستاد مرکزي"
}
],
"ThirdPartyExpertiseClaim": "60",
"CarBodyExpertiseClaim": "61"
},
{
"nationalCode": "0056888082",
"mobile": "09122406750",
"firstName": "قاسم",
"lastName": "نصراللهي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyExpertiseClaim": "4645"
},
{
"nationalCode": "0670358118",
"mobile": "09124421539",
"firstName": "مجيد",
"lastName": "اميري",
"locations": [
{
"id": "210040",
"name": "شعبه شرق تهران(210040)"
}
],
"ThirdPartyExpertiseClaim": "3985",
"CarBodyExpertiseClaim": "3986"
},
{
"nationalCode": "0083730397",
"mobile": "09125759960",
"firstName": "مجيد",
"lastName": "کاظمي دولت سرا",
"locations": [
{
"id": "210120",
"name": "شعبه والفجر"
}
],
"ThirdPartyExpertiseClaim": "3992",
"CarBodyExpertiseClaim": "3991"
},
{
"nationalCode": "0493217789",
"mobile": "09126966943",
"firstName": "مصطفي",
"lastName": "محمدزاده قورقچي",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
}
],
"ThirdPartyExpertiseClaim": "3545",
"CarBodyExpertiseClaim": "3544"
},
{
"nationalCode": "0076988961",
"mobile": "09108357378",
"firstName": "مهدي",
"lastName": "روشن دل",
"locations": [
{
"id": "210110",
"name": "شعبه پونک"
}
],
"CarBodyExpertiseClaim": "3988",
"ThirdPartyExpertiseClaim": "3989"
},
{
"nationalCode": "0078209129",
"mobile": "09126038117",
"firstName": "مهدي",
"lastName": "شاملوفرد",
"locations": [
{
"id": "210050",
"name": "شعبه غرب تهران(210050)"
},
{
"id": "210110",
"name": "شعبه پونک"
}
],
"CarBodyExpertiseClaim": "73",
"ThirdPartyExpertiseClaim": "72"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
/**
* One-time seed for Parsian (clientCode=8) Tehran branches + field experts.
* One-time seed for Parsian (clientCode=8) Tehran branches + file reviewers + file makers.
*
* Usage (before starting the app):
* npm run seed:parsian-tehran
*
* Optional env:
* SEED_PARSIAN_TEHRAN_DEFAULT_PASSWORD=Parsian@724
*
* Backward-compatible env alias:
* SEED_FIELD_EXPERT_DEFAULT_PASSWORD=Parsian@724
*/
import { readFileSync, existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import * as crypto from "node:crypto";
import mongoose, { Schema, Types } from "mongoose";
@@ -23,17 +26,29 @@ type BranchSeed = {
isActive?: boolean;
};
type FieldExpertSeed = {
type ExpertLocationSeed = {
id: string;
name: string;
};
type FileReviewerSeed = {
nationalCode: string;
mobile?: string;
firstName: string;
lastName: string;
branchCode: string;
branchName?: string;
city?: string;
state?: string;
title?: string;
expertCode?: string;
locations: ExpertLocationSeed[];
ThirdPartyExpertiseClaim?: string;
CarBodyExpertiseClaim?: string;
};
type FileMakerSeed = {
nationalCode: string;
mobile?: string;
firstName: string;
lastName: string;
locations: ExpertLocationSeed[];
ThirdPartyClaimExpertId?: string;
CarBodyClaimExpertId?: string;
};
function stripQuotes(value: string): string {
@@ -76,7 +91,6 @@ function loadEnvFile() {
process.env[key] = stripQuotes(value);
}
// Expand ${VAR} placeholders (same as Nest ConfigModule expandVariables).
for (let pass = 0; pass < 5; pass++) {
let changed = false;
for (const key of Object.keys(process.env)) {
@@ -110,7 +124,7 @@ function resolveMongoUri(): string {
return uri;
}
async function ensureFieldExpertIndexes(collection: mongoose.Collection) {
async function ensureUserIndexes(collection: mongoose.Collection) {
const indexes = await collection.indexes();
const emailIndex = indexes.find((idx) => idx.key?.email === 1);
if (emailIndex && !emailIndex.sparse) {
@@ -134,6 +148,17 @@ function hashPassword(password: string): Promise<string> {
});
}
function normalizeLocations(locations: ExpertLocationSeed[]): ExpertLocationSeed[] {
const deduped = new Map<string, ExpertLocationSeed>();
for (const location of locations ?? []) {
const id = String(location?.id ?? "").trim();
const name = String(location?.name ?? "").trim();
if (!id || !name || deduped.has(id)) continue;
deduped.set(id, { id, name });
}
return [...deduped.values()];
}
const ClientSchema = new Schema(
{
clientName: { type: Object, required: true },
@@ -159,7 +184,15 @@ const BranchSchema = new Schema(
BranchSchema.index({ clientKey: 1, code: 1 }, { unique: true });
const FieldExpertSchema = new Schema(
const ExpertLocationSchema = new Schema(
{
id: { type: String, required: true },
name: { type: String, required: true },
},
{ _id: false, id: false, versionKey: false },
);
const FileReviewerSchema = new Schema(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
@@ -168,21 +201,180 @@ const FieldExpertSchema = new Schema(
nationalCode: { type: String, index: true, sparse: true },
clientKey: { type: Schema.Types.ObjectId, index: true },
branchId: { type: Schema.Types.ObjectId, index: true },
locations: { type: [ExpertLocationSchema], default: [] },
password: { type: String, required: true },
mobile: { type: String },
phone: { type: String },
role: { type: String, default: "field_expert" },
role: { type: String, default: "file_reviewer" },
otp: { type: String, default: "" },
expertCode: { type: String, required: false },
ThirdPartyExpertiseClaim: { type: String, required: false },
CarBodyExpertiseClaim: { type: String, required: false },
},
{ collection: "field-expert", versionKey: false, timestamps: true },
{ collection: "file-reviewer", versionKey: false, timestamps: true },
);
FieldExpertSchema.index(
FileReviewerSchema.index(
{ clientKey: 1, nationalCode: 1 },
{ unique: true, sparse: true },
);
const FileMakerSchema = new Schema(
{
firstName: { type: String, required: true },
lastName: { type: String, required: true },
email: { type: String, unique: true, sparse: true },
username: { type: String },
nationalCode: { type: String, index: true, sparse: true },
clientKey: { type: Schema.Types.ObjectId, index: true },
branchId: { type: Schema.Types.ObjectId, index: true },
locations: { type: [ExpertLocationSchema], default: [] },
password: { type: String, required: true },
mobile: { type: String },
phone: { type: String },
role: { type: String, default: "file_maker" },
otp: { type: String, default: "" },
expertCode: { type: String, required: false },
ThirdPartyClaimExpertId: { type: String, required: false },
CarBodyClaimExpertId: { type: String, required: false },
},
{ collection: "file-maker", versionKey: false, timestamps: true },
);
FileMakerSchema.index(
{ clientKey: 1, nationalCode: 1 },
{ unique: true, sparse: true },
);
function resolveSeedLocations(
seedLocations: ExpertLocationSeed[],
branchMetaByCode: Map<string, { _id: Types.ObjectId; name: string }>,
) {
const resolved: { id: string; name: string; branchId: Types.ObjectId }[] = [];
const skippedCodes: string[] = [];
for (const location of normalizeLocations(seedLocations)) {
const branch = branchMetaByCode.get(location.id);
if (!branch) {
skippedCodes.push(location.id);
continue;
}
resolved.push({
id: location.id,
name: branch.name || location.name,
branchId: branch._id,
});
}
return {
resolved,
skippedCodes,
primaryBranchId: resolved[0]?.branchId,
};
}
async function upsertRoleUsers({
label,
seeds,
model,
clientKey,
hashedPassword,
branchMetaByCode,
role,
codeFields,
}: {
label: string;
seeds: Array<Record<string, any>>;
model: mongoose.Model<any>;
clientKey: Types.ObjectId;
hashedPassword: string;
branchMetaByCode: Map<string, { _id: Types.ObjectId; name: string }>;
role: string;
codeFields: string[];
}) {
let created = 0;
let updated = 0;
let skipped = 0;
for (const seed of seeds) {
const { resolved, skippedCodes, primaryBranchId } = resolveSeedLocations(
seed.locations,
branchMetaByCode,
);
if (skippedCodes.length > 0) {
console.warn(
`Skipping unknown ${label} locations for ${seed.nationalCode}: ${skippedCodes.join(
", ",
)}`,
);
}
if (!primaryBranchId || resolved.length === 0) {
console.warn(
`Skipping ${label} ${seed.nationalCode}: no valid branch locations remained`,
);
skipped++;
continue;
}
const setPayload: Record<string, unknown> = {
firstName: seed.firstName,
lastName: seed.lastName,
username: seed.nationalCode,
nationalCode: seed.nationalCode,
clientKey,
branchId: primaryBranchId,
locations: resolved.map(({ id, name }) => ({ id, name })),
role,
otp: "",
};
if (seed.mobile) {
setPayload.mobile = seed.mobile;
}
const unsetPayload: Record<string, ""> = {
expertCode: "",
};
for (const field of codeFields) {
if (seed[field]) {
setPayload[field] = seed[field];
} else {
unsetPayload[field] = "";
}
}
const existing = await model.findOne({
clientKey,
nationalCode: seed.nationalCode,
});
if (existing) {
await model.updateOne(
{ _id: existing._id },
{
$set: {
...setPayload,
password: existing.password,
},
$unset: unsetPayload,
},
);
updated++;
} else {
await model.create({
...setPayload,
password: hashedPassword,
});
created++;
}
}
return { created, updated, skipped };
}
async function main() {
loadEnvFile();
const mongoUri = resolveMongoUri();
@@ -191,12 +383,24 @@ async function main() {
const branchesFile = JSON.parse(
readFileSync(join(dataDir, "branches.json"), "utf8"),
) as { clientCode: number; branches: BranchSeed[] };
const expertsFile = JSON.parse(
readFileSync(join(dataDir, "field-experts.json"), "utf8"),
) as { clientCode: number; fieldExperts: FieldExpertSeed[] };
const fileReviewersFile = JSON.parse(
readFileSync(join(dataDir, "file-reviewers.json"), "utf8"),
) as { clientCode: number; fileReviewers: FileReviewerSeed[] };
const fileMakersFile = JSON.parse(
readFileSync(join(dataDir, "file-makers.json"), "utf8"),
) as { clientCode: number; fileMakers: FileMakerSeed[] };
if (
branchesFile.clientCode !== fileReviewersFile.clientCode ||
branchesFile.clientCode !== fileMakersFile.clientCode
) {
throw new Error("Seed data clientCode mismatch between branch/reviewer/maker files");
}
const defaultPassword =
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ?? "123321";
process.env.SEED_PARSIAN_TEHRAN_DEFAULT_PASSWORD ??
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ??
"123321";
const hashedPassword = await hashPassword(defaultPassword);
await mongoose.connect(mongoUri, {
@@ -204,11 +408,14 @@ async function main() {
tlsAllowInvalidCertificates:
process.env.MONGO_TLS_ALLOW_INVALID_CERTS === "true",
});
const Client = mongoose.model("ClientSeedClient", ClientSchema);
const Branch = mongoose.model("ClientSeedBranch", BranchSchema);
const FieldExpert = mongoose.model("ClientSeedFieldExpert", FieldExpertSchema);
const FileReviewer = mongoose.model("ClientSeedFileReviewer", FileReviewerSchema);
const FileMaker = mongoose.model("ClientSeedFileMaker", FileMakerSchema);
await ensureFieldExpertIndexes(FieldExpert.collection);
await ensureUserIndexes(FileReviewer.collection);
await ensureUserIndexes(FileMaker.collection);
const client = await Client.findOne({
clientCode: branchesFile.clientCode,
@@ -220,7 +427,7 @@ async function main() {
}
const clientKey = new Types.ObjectId(String(client._id));
const branchIdByCode = new Map<string, Types.ObjectId>();
const branchMetaByCode = new Map<string, { _id: Types.ObjectId; name: string }>();
let branchesCreated = 0;
let branchesUpdated = 0;
@@ -239,67 +446,45 @@ async function main() {
phoneNumber: branch.phoneNumber,
isActive: branch.isActive ?? true,
};
if (existing) {
await Branch.updateOne({ _id: existing._id }, { $set: payload });
branchIdByCode.set(branch.code, existing._id as Types.ObjectId);
branchMetaByCode.set(branch.code, {
_id: existing._id as Types.ObjectId,
name: branch.name,
});
branchesUpdated++;
} else {
const created = await Branch.create(payload);
branchIdByCode.set(branch.code, created._id as Types.ObjectId);
branchMetaByCode.set(branch.code, {
_id: created._id as Types.ObjectId,
name: branch.name,
});
branchesCreated++;
}
}
let expertsCreated = 0;
let expertsUpdated = 0;
let expertsSkipped = 0;
const fileReviewersResult = await upsertRoleUsers({
label: "file-reviewer",
seeds: fileReviewersFile.fileReviewers,
model: FileReviewer,
clientKey,
hashedPassword,
branchMetaByCode,
role: "file_reviewer",
codeFields: ["ThirdPartyExpertiseClaim", "CarBodyExpertiseClaim"],
});
for (const expert of expertsFile.fieldExperts) {
const branchId = branchIdByCode.get(expert.branchCode);
if (!branchId) {
console.warn(
`Skipping ${expert.nationalCode}: unknown branch ${expert.branchCode}`,
);
expertsSkipped++;
continue;
}
const payload = {
firstName: expert.firstName,
lastName: expert.lastName,
username: expert.nationalCode,
nationalCode: expert.nationalCode,
clientKey,
branchId,
password: hashedPassword,
mobile: expert.mobile,
role: "field_expert",
otp: "",
expertCode: expert.expertCode,
};
const existing = await FieldExpert.findOne({
clientKey,
nationalCode: expert.nationalCode,
});
if (existing) {
await FieldExpert.updateOne(
{ _id: existing._id },
{
$set: {
...payload,
// Do not rotate password on re-seed unless explicitly desired.
password: existing.password,
},
},
);
expertsUpdated++;
} else {
await FieldExpert.create(payload);
expertsCreated++;
}
}
const fileMakersResult = await upsertRoleUsers({
label: "file-maker",
seeds: fileMakersFile.fileMakers,
model: FileMaker,
clientKey,
hashedPassword,
branchMetaByCode,
role: "file_maker",
codeFields: ["ThirdPartyClaimExpertId", "CarBodyClaimExpertId"],
});
console.log("Parsian Tehran seed completed.");
console.log({
@@ -307,11 +492,15 @@ async function main() {
clientKey: String(clientKey),
branchesCreated,
branchesUpdated,
expertsCreated,
expertsUpdated,
expertsSkipped,
fileReviewersCreated: fileReviewersResult.created,
fileReviewersUpdated: fileReviewersResult.updated,
fileReviewersSkipped: fileReviewersResult.skipped,
fileMakersCreated: fileMakersResult.created,
fileMakersUpdated: fileMakersResult.updated,
fileMakersSkipped: fileMakersResult.skipped,
defaultPassword,
loginHint: "Use nationalCode + password on POST /actor/login with role field_expert",
loginHint:
"Use nationalCode + password on POST /actor/login with role file_reviewer or file_maker",
});
await mongoose.disconnect();

View File

@@ -0,0 +1,233 @@
import { buildInsurerFileReport } from "./case-expert-report.builder";
import { PR } from "./persian-report-labels";
import { toJalaliDateAndTime } from "../helpers/date-jalali";
describe("buildInsurerFileReport", () => {
const getFieldValue = (
report: ReturnType<typeof buildInsurerFileReport>,
sectionTitle: string,
fieldLabel: string,
) => {
const section = report.sections.find((item) => item.title === sectionTitle);
expect(section).toBeDefined();
const field = section?.fields.find((item) => item.label === fieldLabel);
expect(field).toBeDefined();
return field?.value;
};
it("separates insurance blocks and exposes Fanavaran/timeline/evaluation data", () => {
const fileCreatedAt = new Date("2026-08-10T09:53:23.588Z");
const evaluationSubmittedAt = new Date("2026-08-10T12:30:45.000Z");
const [fileDate, fileTime] = toJalaliDateAndTime(fileCreatedAt);
const [evaluationDate, evaluationTime] =
toJalaliDateAndTime(evaluationSubmittedAt);
const report = buildInsurerFileReport({
overview: {
publicId: "A00010",
requestNo: "REQ-10",
createdAt: fileCreatedAt,
},
blame: {
type: "THIRD_PARTY",
blameStatus: "AGREED",
createdAt: fileCreatedAt,
parties: [
{
role: "FIRST",
person: {
userId: "guilty-user-id",
fullName: "مقصر نمونه",
phoneNumber: "09120000000",
nationalCodeOfInsurer: "0987654321",
insurerBirthday: 13650115,
clientId: "client-guilty",
},
statement: {
admitsGuilt: true,
acceptsExpertOpinion: true,
description: "توضیحات مقصر",
},
vehicle: {
carName: "206",
carModel: "1401",
plate: {
leftDigits: 98,
centerAlphabet: "ج",
centerDigits: 765,
ir: 22,
},
},
insurance: {
policyNumber: "TP-GUILTY-001",
company: "بیمه ثالث مقصر",
startDate: "1405/01/01",
endDate: "1406/01/01",
financialCeiling: "900000000",
carBodyInsurance: {
policyNumber: "CB-GUILTY-001",
insurerCompany: "بیمه بدنه مقصر",
startDate: "1405/02/01",
endDate: "1406/02/01",
},
},
},
{
role: "SECOND",
person: {
userId: "damaged-user-id",
fullName: "زیان دیده نمونه",
phoneNumber: "09121111111",
nationalCodeOfInsurer: "1234567890",
clientId: "client-damaged",
insurerBirthday: 13700101,
},
statement: {
claimsDamage: true,
acceptsExpertOpinion: false,
description: "توضیحات زیان‌دیده",
accidentDate: "2026-08-10",
accidentTime: "13:23",
},
insurance: {
policyNumber: "TP-DAMAGED-001",
company: "بیمه ثالث زیان‌دیده",
startDate: "1405/03/01",
endDate: "1406/03/01",
financialCeiling: "700000000",
carBodyInsurance: {
policyNumber: "CB-DAMAGED-001",
insurerCompany: "بیمه بدنه زیان‌دیده",
startDate: "1405/04/01",
endDate: "1406/04/01",
coverages: ["سرقت", "آتش‌سوزی"],
},
},
vehicle: {
carName: "207",
carModel: "1402",
plate: {
leftDigits: 12,
centerAlphabet: "ب",
centerDigits: 345,
ir: 11,
},
},
},
],
expert: {
decision: {
guiltyPartyId: "guilty-user-id",
description: "مقصر شناخته شد",
fields: {
accidentWay: { label: "از جلو" },
accidentReason: { label: "عدم رعایت فاصله" },
accidentType: { label: "برخورد" },
},
},
},
},
claim: {
claimStatus: "APPROVED",
claimNo: 111,
claimId: 222,
dmgCaseId: 333,
expertiseId: 444,
owner: { fullName: "زیان دیده نمونه" },
vehicle: {
carName: "207",
carModel: "1402",
carType: "sedan",
},
fanavaranSync: {
baseClaim: {
policyId: 555,
driverId: 666,
vehicleKindId: 777,
insuranceCorpId: 888,
},
},
evaluation: {
damageExpertReplyFinal: {
submittedAt: evaluationSubmittedAt,
description: "نیاز به تعویض سپر جلو",
actorDetail: { actorName: "کارشناس خسارت نمونه" },
},
},
},
});
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.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),
).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.fanavaranExpertiseId),
).toBe("444");
expect(getFieldValue(report, PR.fanavaranSection, PR.fanavaranPolicyId)).toBe(
"555",
);
expect(getFieldValue(report, PR.timelineSection, PR.fileRegisteredAt)).toBe(
`${fileDate} ${fileTime}`,
);
expect(
getFieldValue(report, PR.timelineSection, PR.evaluationRegisteredAt),
).toBe(`${evaluationDate} ${evaluationTime}`);
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResult)).toBe(
"تأیید شده",
);
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationExpert)).toBe(
"کارشناس خسارت نمونه",
);
expect(getFieldValue(report, PR.evaluationSection, PR.evaluationResponse)).toBe(
"نیاز به تعویض سپر جلو",
);
});
});

View File

@@ -1,4 +1,7 @@
import { resolveDamagedPartyRow } from "src/helpers/blame-damaged-party";
import {
resolveClaimOwnerParty,
resolveDamagedPartyRow,
} from "src/helpers/blame-damaged-party";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import {
@@ -15,8 +18,20 @@ const SKIP_FLATTEN_KEYS = new Set([
"workflow",
"evidence",
"confirmation",
"inquiries",
"raw",
]);
type ReportRecord = Record<string, unknown>;
type ReportParty = ReportRecord & {
role?: string;
person?: ReportRecord;
insurance?: ReportRecord & { carBodyInsurance?: ReportRecord };
vehicle?: ReportRecord;
statement?: ReportRecord;
location?: { lat?: number; lon?: number };
};
function asString(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (value instanceof Date) {
@@ -42,6 +57,30 @@ function formatBirthDate(value: unknown): string | undefined {
return raw;
}
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);
if (Number.isNaN(date.getTime())) return asString(value);
const [d, t] = toJalaliDateAndTime(date);
return `${d} ${t}`;
}
function firstDefined(...values: unknown[]): string | undefined {
for (const value of values) {
const str = asString(value);
if (str) return str;
}
return undefined;
}
function normalizeListValue(value: unknown): string | undefined {
if (!Array.isArray(value) || !value.length) return undefined;
const items = value
.map((item) => asString(item) ?? JSON.stringify(item))
.filter(Boolean);
return items.length ? items.join("، ") : undefined;
}
function flattenObject(
obj: unknown,
prefix = "",
@@ -53,13 +92,10 @@ function flattenObject(
}
if (Array.isArray(obj)) {
if (!obj.length) return [];
return [
{
label: persianFieldPath(prefix || "items"),
value: obj.map((item) => asString(item) ?? JSON.stringify(item)).join("، "),
},
];
const value = normalizeListValue(obj);
return value
? [{ label: persianFieldPath(prefix || "items"), value }]
: [];
}
if (typeof obj !== "object") {
@@ -70,6 +106,7 @@ function flattenObject(
const objRecord = obj as Record<string, unknown>;
const hasMapped =
objRecord.mapped != null && typeof objRecord.mapped === "object";
for (const [key, value] of Object.entries(objRecord)) {
if (SKIP_FLATTEN_KEYS.has(key)) continue;
if (key === "raw" && hasMapped) continue;
@@ -86,9 +123,49 @@ function flattenObject(
rows.push({ label: persianFieldPath(path), value: asString(value) });
}
}
return rows;
}
function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] {
const seen = new Set<string>();
const out: InsurerFileReportField[] = [];
for (const field of fields) {
const value = asString(field.value);
if (!value) continue;
const key = `${field.label}::${value}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ label: field.label, value });
}
return out;
}
function filterEmptySections(
sections: Array<InsurerFileReportSection | undefined>,
): InsurerFileReportSection[] {
return sections.filter(
(section): section is InsurerFileReportSection => !!section && section.fields.length > 0,
);
}
function buildSection(
title: string,
fields: InsurerFileReportField[],
withPlaceholder = true,
): InsurerFileReportSection {
const deduped = dedupeFields(fields);
return {
title,
fields:
deduped.length || !withPlaceholder
? deduped
: [{ label: PR.data, value: PR.empty }],
};
}
function expertNameFromSnapshot(snapshot?: {
firstName?: string;
lastName?: string;
@@ -146,7 +223,6 @@ function inquiryRoleData(
return data;
}
/** Prefer mapped Tejarat fields; avoid duplicating the entire raw blob in PDF. */
function pickInquiryReportPayload(
inquiry?: Record<string, unknown>,
): Record<string, unknown> | undefined {
@@ -159,184 +235,506 @@ function pickInquiryReportPayload(
return Object.keys(rest).length ? rest : inquiry;
}
function licenseFieldsFromInquiry(
inquiry?: Record<string, unknown>,
): { licenseType?: string; licenseDate?: string } {
if (!inquiry) return {};
const candidates = [
inquiry.LicenseType,
inquiry.licenseType,
inquiry.Type,
inquiry.type,
inquiry.LicenseCategory,
inquiry.licenseCategory,
];
const licenseType = candidates.find((v) => v != null && v !== "");
const dateCandidates = [
inquiry.IssueDate,
inquiry.issueDate,
inquiry.LicenseIssueDate,
inquiry.licenseIssueDate,
inquiry.ExpireDate,
inquiry.expireDate,
];
const licenseDate = dateCandidates.find((v) => v != null && v !== "");
function resolveReportBlameContext(
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
): Record<string, unknown> | null {
if (blame) return blame;
const snapshot = claim?.snapshot as Record<string, unknown> | undefined;
if (!snapshot) return null;
return {
licenseType: asString(licenseType),
licenseDate: asString(licenseDate),
type:
(claim?.blameFileContext as Record<string, unknown> | undefined)
?.blameRequestType ??
(snapshot.accident as Record<string, unknown> | undefined)?.type,
blameStatus: (claim?.blameFileContext as Record<string, unknown> | undefined)
?.blameStatus,
parties: snapshot.parties,
};
}
function buildOwnerSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
function getPartyRole(party: ReportParty | null | undefined): string | undefined {
const role = party?.role;
return typeof role === "string" ? role : undefined;
}
function partyKindLabel(
party: ReportParty | null | undefined,
damagedParty: ReportParty | null,
guiltyParty: ReportParty | null,
): string | undefined {
if (sameParty(party, damagedParty)) return "damaged";
if (sameParty(party, guiltyParty)) return "guilty";
return undefined;
}
function partyRoleLabel(role: string | undefined): string | undefined {
if (!role) return undefined;
if (role === PartyRole.FIRST) return "طرف اول";
if (role === PartyRole.SECOND) return "طرف دوم";
return role;
}
function statementBoolean(value: unknown): string | undefined {
if (typeof value !== "boolean") return undefined;
return persianStatus(value);
}
function sameParty(
first: ReportParty | null | undefined,
second: ReportParty | null | undefined,
): boolean {
if (!first || !second) return false;
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;
return getPartyRole(first) === getPartyRole(second);
}
function resolveEvaluationReply(
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const person = damagedParty?.person;
): Record<string, unknown> | undefined {
const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
return (
(evaluation?.damageExpertReplyFinal as Record<string, unknown> | undefined) ??
(evaluation?.damageExpertReply as Record<string, unknown> | undefined)
);
}
function insuranceValueFromCandidates(
...values: unknown[]
): string | undefined {
for (const value of values) {
if (Array.isArray(value)) {
const listValue = normalizeListValue(value);
if (listValue) return listValue;
continue;
}
const str = asString(value);
if (str) return str;
}
return undefined;
}
function buildThirdPartyInsuranceFields(
party: ReportParty | null | undefined,
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
): InsurerFileReportField[] {
const role = getPartyRole(party);
const direct = (party?.insurance ?? {}) as Record<string, unknown>;
const blameInquiry = pickInquiryReportPayload(
inquiryRoleData(blame?.inquiries as Record<string, unknown> | undefined, "thirdParty", role),
);
const claimInquiry = pickInquiryReportPayload(
inquiryRoleData(claim?.inquiries as Record<string, unknown> | undefined, "thirdParty", role),
);
return [
{
label: PR.policyNumber,
value: insuranceValueFromCandidates(
direct.policyNumber,
blameInquiry?.policyNumber,
blameInquiry?.PolicyNumber,
claimInquiry?.policyNumber,
claimInquiry?.PolicyNumber,
blameInquiry?.ThirdPolicyCode,
claimInquiry?.ThirdPolicyCode,
),
},
{
label: PR.insuranceCompany,
value: insuranceValueFromCandidates(
direct.company,
direct.insurerCompany,
blameInquiry?.company,
blameInquiry?.CompanyName,
claimInquiry?.company,
claimInquiry?.CompanyName,
),
},
{
label: PR.policyStartDate,
value: insuranceValueFromCandidates(
direct.startDate,
blameInquiry?.startDate,
blameInquiry?.PolicyStartDate,
blameInquiry?.SatrtDate,
claimInquiry?.startDate,
claimInquiry?.PolicyStartDate,
claimInquiry?.SatrtDate,
),
},
{
label: PR.policyEndDate,
value: insuranceValueFromCandidates(
direct.endDate,
blameInquiry?.endDate,
blameInquiry?.PolicyEndDate,
blameInquiry?.EndDate,
claimInquiry?.endDate,
claimInquiry?.PolicyEndDate,
claimInquiry?.EndDate,
),
},
{
label: PR.financialCeiling,
value: insuranceValueFromCandidates(
direct.financialCeiling,
blameInquiry?.financialCeiling,
blameInquiry?.FinancialCvrCptl,
blameInquiry?.FnCvrCptl,
claimInquiry?.financialCeiling,
claimInquiry?.FinancialCvrCptl,
claimInquiry?.FnCvrCptl,
),
},
{
label: PR.coverages,
value: insuranceValueFromCandidates(
direct.coverages,
blameInquiry?.coverages,
claimInquiry?.coverages,
),
},
];
}
function buildCarBodyInsuranceFields(
party: ReportParty | null | undefined,
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
): InsurerFileReportField[] {
const role = getPartyRole(party);
const direct = (party?.insurance?.carBodyInsurance ??
party?.insurance?.carBody ??
{}) as Record<string, unknown>;
const blameInquiry = pickInquiryReportPayload(
inquiryRoleData(blame?.inquiries as Record<string, unknown> | undefined, "carBody", role),
);
const claimInquiry = pickInquiryReportPayload(
inquiryRoleData(claim?.inquiries as Record<string, unknown> | undefined, "carBody", role),
);
const legacy = (blame?.carBodyInsuranceDetail ?? {}) as Record<string, unknown>;
return [
{
label: PR.policyNumber,
value: insuranceValueFromCandidates(
direct.policyNumber,
legacy.policyNumber,
blameInquiry?.policyNumber,
blameInquiry?.PolicyNumber,
claimInquiry?.policyNumber,
claimInquiry?.PolicyNumber,
),
},
{
label: PR.insuranceCompany,
value: insuranceValueFromCandidates(
direct.insurerCompany,
direct.company,
legacy.insurerCompany,
blameInquiry?.company,
blameInquiry?.CompanyName,
claimInquiry?.company,
claimInquiry?.CompanyName,
),
},
{
label: PR.policyStartDate,
value: insuranceValueFromCandidates(
direct.startDate,
legacy.startDate,
blameInquiry?.startDate,
blameInquiry?.PolicyStartDate,
claimInquiry?.startDate,
claimInquiry?.PolicyStartDate,
),
},
{
label: PR.policyEndDate,
value: insuranceValueFromCandidates(
direct.endDate,
legacy.endDate,
blameInquiry?.endDate,
blameInquiry?.PolicyEndDate,
claimInquiry?.endDate,
claimInquiry?.PolicyEndDate,
),
},
{
label: PR.coverages,
value: insuranceValueFromCandidates(
direct.coverages,
legacy.coverages,
blameInquiry?.coverages,
claimInquiry?.coverages,
),
},
];
}
function buildPartyOwnerSection(
title: string,
party: ReportParty | null | undefined,
options?: {
claim?: Record<string, unknown> | null;
useClaimOwnerFallback?: boolean;
includeSheba?: boolean;
},
): InsurerFileReportSection | undefined {
const person = party?.person as ReportRecord | undefined;
const claim = options?.claim;
const money = claim?.money as
| { sheba?: string; nationalCodeOfInsurer?: string }
| undefined;
const claimOwner = claim?.owner as { fullName?: string } | undefined;
if (!person && !options?.useClaimOwnerFallback) return undefined;
return buildSection(title, [
{
label: PR.name,
value: options?.useClaimOwnerFallback
? firstDefined(person?.fullName, claimOwner?.fullName)
: firstDefined(person?.fullName),
},
{ label: PR.phone, value: asString(person?.phoneNumber) },
{
label: PR.nationalCode,
value: options?.useClaimOwnerFallback
? firstDefined(person?.nationalCodeOfInsurer, money?.nationalCodeOfInsurer)
: firstDefined(person?.nationalCodeOfInsurer, person?.nationalCode),
},
{
label: PR.birthDate,
value: formatBirthDate(
person?.insurerBirthday ?? person?.birthday ?? person?.driverBirthday,
),
},
{
label: PR.sheba,
value: options?.includeSheba ? money?.sheba : undefined,
},
]);
}
function licenseFieldsFromInquiry(
inquiry?: Record<string, unknown>,
): { licenseType?: string; licenseDate?: string } {
if (!inquiry) return {};
return {
title: PR.ownerSection,
fields: [
{ label: PR.name, value: person?.fullName ?? claimOwner?.fullName },
{ label: PR.phone, value: person?.phoneNumber },
{
label: PR.nationalCode,
value: person?.nationalCodeOfInsurer ?? money?.nationalCodeOfInsurer,
},
{
label: PR.birthDate,
value: formatBirthDate(person?.insurerBirthday ?? person?.birthday),
},
{ label: PR.sheba, value: money?.sheba },
],
licenseType: firstDefined(
inquiry.LicenseType,
inquiry.licenseType,
inquiry.Type,
inquiry.type,
inquiry.LicenseCategory,
inquiry.licenseCategory,
),
licenseDate: firstDefined(
inquiry.IssueDate,
inquiry.issueDate,
inquiry.LicenseIssueDate,
inquiry.licenseIssueDate,
inquiry.ExpireDate,
inquiry.expireDate,
),
};
}
function buildDriverSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
damagedParty: ReportParty | null,
blame?: Record<string, unknown> | null,
): InsurerFileReportSection | undefined {
const person = damagedParty?.person;
const person = damagedParty?.person as ReportRecord | undefined;
if (!person || person.driverIsInsurer !== false) return undefined;
const role = damagedParty?.role ?? PartyRole.FIRST;
const licenseInquiry = inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"drivingLicence",
role,
String(role),
);
const { licenseType, licenseDate } = licenseFieldsFromInquiry(licenseInquiry);
return {
title: PR.driverSection,
fields: [
{ label: PR.name, value: person.fullName },
{
label: PR.licenseType,
value: licenseType ?? (person.driverLicense ? PR.driverLicense : undefined),
},
{
label: PR.licenseDate,
value: licenseDate ?? person.driverLicense,
},
{ label: PR.phone, value: person.phoneNumber },
{ label: PR.nationalCode, value: person.nationalCodeOfDriver },
{ label: PR.birthDate, value: formatBirthDate(person.driverBirthday) },
{ label: PR.licenseNumber, value: person.driverLicense },
],
};
}
function buildInsuranceSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const role = damagedParty?.role ?? PartyRole.FIRST;
const insurance = damagedParty?.insurance ?? {};
const carBodyLegacy = blame?.carBodyInsuranceDetail as
| Record<string, unknown>
| undefined;
const thirdPartyInquiry = inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"thirdParty",
role,
);
const carBodyInquiry = inquiryRoleData(
blame?.inquiries as Record<string, unknown> | undefined,
"carBody",
role,
);
const claimThirdParty = inquiryRoleData(
claim?.inquiries as Record<string, unknown> | undefined,
"thirdParty",
role,
);
const claimCarBody = inquiryRoleData(
claim?.inquiries as Record<string, unknown> | undefined,
"carBody",
role,
);
const fields: InsurerFileReportField[] = [
...flattenObject(insurance, "party.insurance"),
...flattenObject(
(insurance as { carBodyInsurance?: unknown }).carBodyInsurance,
"party.insurance.carBodyInsurance",
),
...flattenObject(
pickInquiryReportPayload(thirdPartyInquiry),
"inquiry.thirdParty",
),
...flattenObject(pickInquiryReportPayload(carBodyInquiry), "inquiry.carBody"),
...flattenObject(
pickInquiryReportPayload(claimThirdParty),
"claim.inquiry.thirdParty",
),
...flattenObject(
pickInquiryReportPayload(claimCarBody),
"claim.inquiry.carBody",
),
...flattenObject(carBodyLegacy, "blame.carBodyInsuranceDetail"),
];
const deduped = dedupeFields(fields);
return {
title: PR.insuranceSection,
fields: deduped.length ? deduped : [{ label: PR.data, value: PR.empty }],
};
}
function buildVehicleSection(
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const partyVehicle = damagedParty?.vehicle;
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
const fields = dedupeFields([
...flattenObject(claimVehicle, "claim.vehicle"),
...flattenObject(partyVehicle, "party.vehicle"),
return buildSection(PR.driverSection, [
{ label: PR.name, value: asString(person.fullName) },
{
label: PR.licenseType,
value: licenseType ?? (person.driverLicense ? PR.driverLicense : undefined),
},
{
label: PR.licenseDate,
value: licenseDate ?? asString(person.driverLicense),
},
{ label: PR.phone, value: asString(person.phoneNumber) },
{ label: PR.nationalCode, value: asString(person.nationalCodeOfDriver) },
{ label: PR.birthDate, value: formatBirthDate(person.driverBirthday) },
{ label: PR.licenseNumber, value: asString(person.driverLicense) },
]);
}
return {
title: PR.vehicleSection,
fields: fields.length ? fields : [{ label: PR.data, value: PR.empty }],
};
function buildPartyVehicleSection(
title: string,
party: ReportParty | null | undefined,
claimVehicle?: Record<string, unknown>,
): InsurerFileReportSection | undefined {
if (!party && !claimVehicle) return undefined;
return buildSection(title, [
...flattenObject(claimVehicle, "claim.vehicle"),
...flattenObject(party?.vehicle, "party.vehicle"),
]);
}
function buildPartyStatementSection(
title: string,
party: ReportParty | null | undefined,
damagedParty: ReportParty | null,
guiltyParty: ReportParty | null,
): InsurerFileReportSection | undefined {
if (!party) return undefined;
const statement = (party.statement ?? {}) as ReportRecord;
const kind = partyKindLabel(party, damagedParty, guiltyParty);
return buildSection(title, [
{
label: PR.partyRole,
value: partyRoleLabel(getPartyRole(party)),
},
{
label: PR.name,
value: firstDefined(party.person?.fullName),
},
{
label: PR.admitsGuilt,
value:
kind === "damaged"
? undefined
: statementBoolean(statement.admitsGuilt),
},
{
label: PR.claimsDamage,
value:
kind === "guilty"
? undefined
: statementBoolean(statement.claimsDamage),
},
{
label: PR.acceptsExpertOpinion,
value: statementBoolean(statement.acceptsExpertOpinion),
},
{
label: PR.partyDescription,
value: asString(statement.description),
},
]);
}
function buildCaseTimelineSection(
overview?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
): InsurerFileReportSection {
const evaluationReply = resolveEvaluationReply(claim);
return buildSection(PR.timelineSection, [
{
label: PR.fileRegisteredAt,
value: formatDateTime(overview?.createdAt),
},
{
label: PR.evaluationRegisteredAt,
value: formatDateTime(evaluationReply?.submittedAt),
},
]);
}
function buildFanavaranCodesSection(
claim?: Record<string, unknown> | null,
): InsurerFileReportSection | undefined {
if (!claim) return undefined;
const sync = (claim.fanavaranSync as Record<string, unknown> | undefined) ?? {};
const baseClaim = (sync.baseClaim as Record<string, unknown> | undefined) ?? {};
const damageCase = (sync.damageCase as Record<string, unknown> | undefined) ?? {};
const expertise = (sync.expertise as Record<string, unknown> | undefined) ?? {};
return buildSection(PR.fanavaranSection, [
{
label: PR.fanavaranClaimNo,
value: firstDefined(claim.claimNo, baseClaim.claimNo),
},
{
label: PR.fanavaranClaimId,
value: firstDefined(claim.claimId, baseClaim.claimId),
},
{
label: PR.fanavaranDamageCaseId,
value: firstDefined(claim.dmgCaseId, damageCase.dmgCaseId, expertise.dmgCaseId),
},
{
label: PR.fanavaranExpertiseId,
value: firstDefined(claim.expertiseId, expertise.expertiseId),
},
{
label: PR.fanavaranPolicyId,
value: firstDefined(baseClaim.policyId),
},
{
label: PR.fanavaranDriverId,
value: firstDefined(baseClaim.driverId),
},
{
label: PR.fanavaranVehicleKindId,
value: firstDefined(baseClaim.vehicleKindId),
},
{
label: PR.fanavaranInsuranceCorpId,
value: firstDefined(baseClaim.insuranceCorpId),
},
]);
}
function buildEvaluationSection(
claim?: Record<string, unknown> | null,
): InsurerFileReportSection | undefined {
if (!claim) return undefined;
const reply = resolveEvaluationReply(claim);
const actorDetail = reply?.actorDetail as { actorName?: string } | undefined;
const snapshotName = expertNameFromSnapshot(
reply?.expertProfileSnapshot as
| { firstName?: string; lastName?: string }
| undefined,
);
return buildSection(PR.evaluationSection, [
{
label: PR.evaluationResult,
value: persianStatus(claim.claimStatus),
},
{
label: PR.evaluationExpert,
value: actorDetail?.actorName ?? snapshotName,
},
{
label: PR.evaluationSubmittedAt,
value: formatDateTime(reply?.submittedAt),
},
{
label: PR.evaluationResponse,
value: asString(reply?.description),
},
]);
}
function buildAccidentReportSection(
blame?: Record<string, unknown> | null,
claim?: Record<string, unknown> | null,
damagedParty?: ReturnType<typeof resolveDamagedPartyRow>,
damagedParty?: ReportParty | null,
): InsurerFileReportSection {
const statement = damagedParty?.statement as
| Record<string, unknown>
| undefined;
const statement = damagedParty?.statement as ReportRecord | undefined;
const location = damagedParty?.location;
const snapshotAccident = (
claim?.snapshot as { accident?: Record<string, unknown> } | undefined
@@ -344,22 +742,21 @@ function buildAccidentReportSection(
const blameDecision = (
blame?.expert as Record<string, unknown> | undefined
)?.decision as Record<string, unknown> | undefined;
const decisionFields = blameDecision?.fields as
| Record<string, unknown>
| undefined;
const decisionFields = blameDecision?.fields as Record<string, unknown> | undefined;
const accidentDate =
asString(statement?.accidentDate) ??
asString(snapshotAccident?.date) ??
asString(blame?.createdAtFormatted) ??
asString(blame?.createdAt);
const accidentTime =
asString(statement?.accidentTime) ?? asString(snapshotAccident?.time);
const fields: InsurerFileReportField[] = [
{ label: PR.date, value: accidentDate },
{ label: PR.time, value: accidentTime },
return buildSection(PR.accidentSection, [
{
label: PR.accidentDate,
value:
asString(statement?.accidentDate) ??
asString(snapshotAccident?.date) ??
asString(blame?.createdAtFormatted) ??
formatDateTime(blame?.createdAt),
},
{
label: PR.accidentTime,
value: asString(statement?.accidentTime) ?? asString(snapshotAccident?.time),
},
{
label: PR.experts,
value: collectExpertNames(blame, claim),
@@ -412,8 +809,7 @@ 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 };
@@ -432,82 +828,87 @@ function buildAccidentReportSection(
)?.accidentType?.label,
),
},
{
label: PR.damageExpertDate,
value: asString(
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReplyFinal as Record<string, unknown> | undefined
)?.submittedAt ??
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReply as Record<string, unknown> | undefined
)?.submittedAt,
),
},
{
label: PR.damageExpertNotes,
value: asString(
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReplyFinal as Record<string, unknown> | undefined
)?.description ??
(
(claim?.evaluation as Record<string, unknown> | undefined)
?.damageExpertReply as Record<string, unknown> | undefined
)?.description,
),
},
{
label: PR.partyDescription,
value: asString(statement?.description),
},
];
return {
title: PR.accidentSection,
fields: dedupeFields(fields),
};
]);
}
function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] {
const seen = new Set<string>();
const out: InsurerFileReportField[] = [];
for (const field of fields) {
const value = asString(field.value);
if (!value) continue;
const key = `${field.label}::${value}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ label: field.label, value });
}
return out;
}
export function buildInsurerFileReport(
file: {
overview?: Record<string, unknown>;
blame?: Record<string, unknown>;
claim?: Record<string, unknown>;
},
): InsurerFileReportViewModel {
export function buildInsurerFileReport(file: {
overview?: Record<string, unknown>;
blame?: Record<string, unknown>;
claim?: Record<string, unknown>;
}): InsurerFileReportViewModel {
const overview = file.overview ?? {};
const blame = file.blame ?? null;
const claim = file.claim ?? null;
const damagedParty = blame ? resolveDamagedPartyRow(blame) : null;
const blame = file.blame ?? null;
const blameContext = resolveReportBlameContext(blame, claim);
const damagedParty = blameContext
? (resolveDamagedPartyRow(blameContext as any) as ReportParty | null)
: null;
const guiltyParty = blameContext
? (resolveClaimOwnerParty(blameContext as any) as ReportParty | null)
: null;
const isCarBody =
String((blameContext?.type as string | undefined) ?? "") === "CAR_BODY";
const includeGuiltySections = !!guiltyParty && !(isCarBody && sameParty(damagedParty, guiltyParty));
const sections: InsurerFileReportSection[] = [
buildOwnerSection(damagedParty, claim),
];
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
const driverSection = buildDriverSection(damagedParty, blame);
if (driverSection) sections.push(driverSection);
sections.push(
buildInsuranceSection(damagedParty, blame, claim),
buildVehicleSection(damagedParty, claim),
buildAccidentReportSection(blame, claim, damagedParty),
);
const sections = filterEmptySections([
buildCaseTimelineSection(overview, claim),
buildPartyOwnerSection(PR.ownerSection, damagedParty, {
claim,
useClaimOwnerFallback: true,
includeSheba: true,
}),
includeGuiltySections
? buildPartyOwnerSection(PR.guiltyOwnerSection, guiltyParty)
: undefined,
buildDriverSection(damagedParty, blameContext),
buildSection(
PR.damagedThirdPartyInsuranceSection,
buildThirdPartyInsuranceFields(damagedParty, blameContext, claim),
),
buildSection(
PR.damagedCarBodyInsuranceSection,
buildCarBodyInsuranceFields(damagedParty, blameContext, claim),
),
includeGuiltySections
? buildSection(
PR.guiltyThirdPartyInsuranceSection,
buildThirdPartyInsuranceFields(guiltyParty, blameContext, claim),
)
: undefined,
includeGuiltySections
? buildSection(
PR.guiltyCarBodyInsuranceSection,
buildCarBodyInsuranceFields(guiltyParty, blameContext, claim),
)
: undefined,
buildPartyVehicleSection(PR.damagedVehicleSection, damagedParty, claimVehicle),
includeGuiltySections
? buildPartyVehicleSection(PR.guiltyVehicleSection, guiltyParty)
: undefined,
buildPartyStatementSection(
PR.damagedStatementSection,
damagedParty,
damagedParty,
guiltyParty,
),
includeGuiltySections
? buildPartyStatementSection(
PR.guiltyStatementSection,
guiltyParty,
damagedParty,
guiltyParty,
)
: undefined,
buildFanavaranCodesSection(claim),
buildEvaluationSection(claim),
buildAccidentReportSection(blameContext, claim, damagedParty),
]);
return {
title: PR.reportTitle,

View File

@@ -35,7 +35,7 @@ export class CaseExpertReportInsurerController {
@ApiOperation({
summary: "Get insurer file report data",
description:
"Returns the structured report data for the given publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections. The front-end uses this data to render and generate the PDF.",
"Returns structured insurer PDF data for the given publicId (blame + claim combined), including separated guilty/damaged insurance blocks, third-party/body policy details, Fanavaran codes, case/evaluation timestamps, and the evaluation result with expert response.",
})
@ApiParam({ name: "publicId" })
@ApiResponse({ status: 200, description: "Report data" })

View File

@@ -4,9 +4,20 @@ export const PR = {
requestNo: "شماره درخواست",
empty: "-",
ownerSection: "مالک خودروی زیان دیده",
guiltyOwnerSection: "مالک خودروی مقصر",
driverSection: "راننده خودروی زیان دیده",
insuranceSection: "اطلاعات بیمه (بدنه و شخص ثالث)",
damagedThirdPartyInsuranceSection: "بیمه شخص ثالث زیان‌دیده",
damagedCarBodyInsuranceSection: "بیمه بدنه زیان‌دیده",
guiltyThirdPartyInsuranceSection: "بیمه شخص ثالث مقصر",
guiltyCarBodyInsuranceSection: "بیمه بدنه مقصر",
damagedVehicleSection: "اطلاعات خودروی زیان‌دیده",
guiltyVehicleSection: "اطلاعات خودروی مقصر",
vehicleSection: "اطلاعات خودرو",
damagedStatementSection: "اظهارات و اقرار زیان‌دیده",
guiltyStatementSection: "اظهارات و اقرار مقصر",
timelineSection: "زمان‌بندی پرونده",
fanavaranSection: "کدهای فناوران",
evaluationSection: "نتیجه ارزیابی",
accidentSection: "گزارش حادثه",
name: "نام",
phone: "شماره تلفن",
@@ -17,9 +28,35 @@ export const PR = {
licenseDate: "تاریخ گواهینامه",
licenseNumber: "شماره گواهینامه",
driverLicense: "گواهینامه راننده",
insuranceCompany: "شرکت بیمه",
policyNumber: "شماره بیمه‌نامه",
policyStartDate: "تاریخ شروع بیمه‌نامه",
policyEndDate: "تاریخ پایان بیمه‌نامه",
financialCeiling: "سقف تعهد مالی",
coverages: "پوشش‌ها",
fileRegisteredAt: "تاریخ و ساعت ثبت پرونده",
evaluationRegisteredAt: "تاریخ و ساعت ثبت نتیجه ارزیابی",
fanavaranClaimNo: "شماره پرونده فناوران",
fanavaranClaimId: "کد پرونده فناوران",
fanavaranDamageCaseId: "کد کیس خسارت فناوران",
fanavaranExpertiseId: "کد کارشناسی فناوران",
fanavaranPolicyId: "کد بیمه‌نامه فناوران",
fanavaranDriverId: "کد راننده فناوران",
fanavaranVehicleKindId: "کد نوع خودرو فناوران",
fanavaranInsuranceCorpId: "کد شرکت بیمه فناوران",
evaluationResult: "نتیجه ارزیابی",
evaluationExpert: "کارشناس ارزیاب",
evaluationSubmittedAt: "تاریخ و ساعت ثبت ارزیابی",
evaluationResponse: "پاسخ / توضیحات کارشناس",
admitsGuilt: "اقرار به تقصیر",
claimsDamage: "ادعای خسارت",
acceptsExpertOpinion: "پذیرش نظر کارشناس",
partyRole: "نقش طرف",
data: "اطلاعات",
date: "تاریخ",
time: "زمان",
accidentDate: "تاریخ حادثه",
accidentTime: "ساعت حادثه",
experts: "کارشناس(ان)",
location: "موقعیت (عرض و طول جغرافیایی)",
weather: "وضعیت آب و هوا",

View File

@@ -0,0 +1,59 @@
import { ForbiddenException } from "@nestjs/common";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
describe("V2 claim-detail access for split file roles", () => {
const makerId = "maker-id";
const reviewerId = "reviewer-id";
const createService = (blame: Record<string, unknown>) => {
const service = Object.create(
ClaimRequestManagementService.prototype,
) as ClaimRequestManagementService;
(service as any).blameRequestDbService = {
findById: jest.fn().mockResolvedValue(blame),
};
return service;
};
const claim = { blameRequestId: "blame-id" };
it("allows the FileMaker who created a completed V4/V5 file", async () => {
const service = createService({
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
initiatedByFieldExpertId: makerId,
});
await expect(
(service as any).assertActorCanViewClaimV2(claim, makerId, {
sub: makerId,
role: RoleEnum.FILE_MAKER,
}),
).resolves.toBeUndefined();
});
it("allows only the assigned FileReviewer", async () => {
const service = createService({
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
assignedFileReviewerId: reviewerId,
});
await expect(
(service as any).assertActorCanViewClaimV2(claim, reviewerId, {
sub: reviewerId,
role: RoleEnum.FILE_REVIEWER,
}),
).resolves.toBeUndefined();
await expect(
(service as any).assertActorCanViewClaimV2(claim, "other-reviewer", {
sub: "other-reviewer",
role: RoleEnum.FILE_REVIEWER,
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -8005,6 +8005,37 @@ export class ClaimRequestManagementService {
}
}
if (
(actor?.role === RoleEnum.FILE_MAKER ||
actor?.role === RoleEnum.FILE_REVIEWER) &&
claim.blameRequestId
) {
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId.toString(),
);
const isV4OrV5File =
!!(blame as any)?.isMadeByFileMaker &&
blame?.expertInitiated &&
blame?.creationMethod === "IN_PERSON";
if (
actor.role === RoleEnum.FILE_MAKER &&
isV4OrV5File &&
claimCaseInitiatedByFieldExpert(claim, { sub: currentUserId }, blame)
) {
return;
}
if (
actor.role === RoleEnum.FILE_REVIEWER &&
isV4OrV5File &&
String((blame as any)?.assignedFileReviewerId ?? "") ===
String(currentUserId)
) {
return;
}
}
if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) {
const blame = await this.blameRequestDbService.findById(
claim.blameRequestId.toString(),
@@ -10797,16 +10828,41 @@ export class ClaimRequestManagementService {
}
claims = [...byId.values()];
}
const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(),
publicId: c.publicId,
requestNo: c.requestNo,
status: c.status,
claimStatus: c.claimStatus || "PENDING",
currentStep: c.workflow?.currentStep || "",
createdAt: c.createdAt,
blameRequestId: c.blameRequestId?.toString(),
})) as ClaimListItemV2Dto[];
const blameIdsForList = [
...new Set(
(claims as any[])
.map((c) => c.blameRequestId?.toString())
.filter((id): id is string => !!id),
),
];
const blamesForList =
blameIdsForList.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIdsForList.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type creationMethod" },
)) as any[])
: [];
const blameByIdForList = new Map<string, any>(
blamesForList.map((b) => [String(b._id), b]),
);
const list = (claims as any[]).map((c) => {
const blameForItem = c.blameRequestId
? blameByIdForList.get(c.blameRequestId.toString())
: undefined;
return {
claimRequestId: c._id.toString(),
publicId: c.publicId,
requestNo: c.requestNo,
status: c.status,
claimStatus: c.claimStatus || "PENDING",
currentStep: c.workflow?.currentStep || "",
createdAt: c.createdAt,
blameRequestId: c.blameRequestId?.toString(),
blameType: blameForItem?.type ?? undefined,
creationMethod: blameForItem?.creationMethod ?? undefined,
};
}) as ClaimListItemV2Dto[];
const paged = applyListQueryV2(
list,
@@ -10856,6 +10912,15 @@ export class ClaimRequestManagementService {
}
await this.assertActorCanViewClaimV2(claim, currentUserId, actor);
const blameForDetail = claim.blameRequestId
? ((
await this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
{ lean: true, select: "type creationMethod" },
)
)[0] ?? null)
: null;
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
@@ -11011,6 +11076,8 @@ export class ClaimRequestManagementService {
nextStep: claim.workflow?.nextStep,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
blameType: (blameForDetail as any)?.type ?? undefined,
creationMethod: (blameForDetail as any)?.creationMethod ?? undefined,
...(ownerData ? { owner: ownerData } : {}),
vehicle: claim.vehicle,
selectedParts: selectedNormDetails,
@@ -11023,6 +11090,14 @@ export class ClaimRequestManagementService {
carAngles,
damagedParts,
expertResend,
fanavaran:
claim.status === ClaimCaseStatus.COMPLETED &&
(claim.claimNo != null || claim.claimId != null)
? {
claimNo: claim.claimNo,
claimId: claim.claimId,
}
: undefined,
evaluation: mappedEvaluation
? {
damageExpertReply: mappedEvaluation.damageExpertReply,

View File

@@ -117,7 +117,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get Claim Details (V2)",
description:
"Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
"Returns the claim snapshot for an authorized **USER**, **FIELD_EXPERT**, **REGISTRAR**, **FILE_MAKER**, or assigned **FILE_REVIEWER**. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`. Completed claims include Fanavaran `claimNo` / `claimId` when available.",
})
@ApiResponse({
status: 200,
@@ -322,7 +322,8 @@ export class ClaimRequestManagementV2Controller {
}
/**
* V2: Owner signature — priced-line gate (mixed factors) or final accept/reject.
* V2–V5: owner signature used only as the priced-line gate for mixed-factor claims.
* The final accept/reject phase is retained for legacy rows only.
*/
@Put("request/:claimRequestId/owner-insurer-approval/sign")
@ApiParam({
@@ -332,12 +333,12 @@ export class ClaimRequestManagementV2Controller {
})
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Sign priced lines or final claim pricing (owner)",
summary: "Sign priced lines before factor uploads (owner; final phase is legacy only)",
description:
"Multipart: `sign`, `agree`, `branchId`. Requires `ClaimCaseStatus` **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, **`INSURER_REVIEW_MIXED_FACTORS_PENDING`**, or legacy **`WAITING_FOR_INSURER_APPROVAL`**, and `workflow.currentStep=INSURER_REVIEW` (not during owner factor upload or `EXPERT_COST_EVALUATION`).\n\n" +
"**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" +
"**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" +
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.",
"**Phase B — Legacy final phase only:** pre-existing rows with `claimStatus=APPROVED` may still be accepted or rejected through this endpoint. New V2–V5 claims complete after expert work (and V5 FileMaker approval) without a final owner signature.\n\n" +
"Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or legacy `FINAL_APPROVAL` for UI state.",
})
@ApiBody({
description: "Signature file, agreement, and branch",

View File

@@ -102,7 +102,7 @@ export class ClaimDetailsV2ResponseDto {
@ApiProperty({
description:
"ClaimCaseStatus; see also `ownerGuidance` for UX. Post-expert: INSURER_REVIEW_AWAITING_OWNER_SIGN | INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS; legacy WAITING_FOR_INSURER_APPROVAL may still appear.",
"ClaimCaseStatus; see also `ownerGuidance` for UX. New V2–V5 priced-only claims are COMPLETED after expert work; factor claims use INSURER_REVIEW_MIXED_FACTORS_PENDING | OWNER_REPAIR_FACTOR_UPLOAD_PENDING | EXPERT_VALIDATING_REPAIR_FACTORS. V5 then uses WAITING_FOR_FILE_MAKER_APPROVAL. Legacy WAITING_FOR_INSURER_APPROVAL or INSURER_REVIEW_AWAITING_OWNER_SIGN may still appear.",
example: "OWNER_REPAIR_FACTOR_UPLOAD_PENDING",
})
status: string;
@@ -122,6 +122,18 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Blame request number' })
blameRequestNo?: string;
@ApiPropertyOptional({
description: 'Blame file type: THIRD_PARTY or CAR_BODY',
example: 'THIRD_PARTY',
})
blameType?: string;
@ApiPropertyOptional({
description: 'How the blame file was initiated: IN_PERSON or LINK',
example: 'IN_PERSON',
})
creationMethod?: string;
@ApiPropertyOptional({
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
})
@@ -198,6 +210,16 @@ export class ClaimDetailsV2ResponseDto {
})
expertResend?: ExpertResendDetailsV2Dto;
@ApiPropertyOptional({
description:
'Fanavaran claim reference. Returned only after the local claim reaches COMPLETED and Fanavaran has supplied at least one reference.',
example: { claimNo: 123456, claimId: 987654 },
})
fanavaran?: {
claimNo?: number;
claimId?: number;
};
@ApiPropertyOptional({
description: "Damage expert opinion(s): initial and final (after objection).",
type: Object,

View File

@@ -12,8 +12,8 @@ export class ClaimListItemV2Dto {
@ApiProperty({
description:
"ClaimCaseStatus. Post-expert owner phase includes: INSURER_REVIEW_AWAITING_OWNER_SIGN (priced lines only → final owner sign); INSURER_REVIEW_MIXED_FACTORS_PENDING (priced + factor lines); OWNER_REPAIR_FACTOR_UPLOAD_PENDING (all lines factor-needed); EXPERT_VALIDATING_REPAIR_FACTORS (all factors uploaded, expert validating). Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL for those flows.",
example: "INSURER_REVIEW_AWAITING_OWNER_SIGN",
"ClaimCaseStatus. New V2–V5 priced-only claims become COMPLETED after expert work. Factor claims use INSURER_REVIEW_MIXED_FACTORS_PENDING (priced-line acceptance before factor uploads), OWNER_REPAIR_FACTOR_UPLOAD_PENDING, and EXPERT_VALIDATING_REPAIR_FACTORS. V5 then waits at WAITING_FOR_FILE_MAKER_APPROVAL. Legacy DB rows may still use WAITING_FOR_INSURER_APPROVAL or INSURER_REVIEW_AWAITING_OWNER_SIGN.",
example: "COMPLETED",
})
status: string;
@@ -28,6 +28,18 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Blame request ID this claim originated from' })
blameRequestId?: string;
@ApiPropertyOptional({
description: 'Blame file type: THIRD_PARTY or CAR_BODY',
example: 'THIRD_PARTY',
})
blameType?: string;
@ApiPropertyOptional({
description: 'How the blame file was initiated: IN_PERSON or LINK',
example: 'IN_PERSON',
})
creationMethod?: string;
}
export class GetMyClaimsV2ResponseDto {

View File

@@ -527,7 +527,7 @@ Returns status of each item (uploaded/captured or not).
);
}
// ─── Owner signature on expert pricing ───────────────────────────────────────
// ─── Mixed-factor priced-line signature ──────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@@ -553,11 +553,12 @@ Returns status of each item (uploaded/captured or not).
})
@ApiOperation({
summary:
"Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)",
"Priced-line acceptance before factor uploads (Flow 3 — expert acts on behalf of user)",
description:
"Field expert submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the expert's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
"For mixed priced/factor claims only, the field expert records the damaged party's " +
"acceptance of priced lines before factor uploads. V2–V5 no longer require a final " +
"owner signature: they complete after expert work. The expert's identity is resolved " +
"to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {

View File

@@ -27,6 +27,15 @@ export class UnifiedFileStatusReportQueryDto {
@IsISO8601({ strict: false })
to?: string;
@ApiPropertyOptional({
enum: [30, 60, 90],
description:
"Preset reporting window in days. Default: 30 when from/to are omitted.",
})
@IsOptional()
@IsIn([30, 60, 90, "30", "60", "90"])
periodDays?: number | string;
@ApiPropertyOptional({
enum: LIST_FILE_TYPE_V2,
description:

View File

@@ -116,6 +116,8 @@ export class AllRequestDtoV2 {
lockFile: boolean;
lockTime: string | null;
type: string;
/** IN_PERSON or LINK — how the blame file was created */
creationMethod?: string;
blameStatus: string;
/** Calculated blame + linked claim lifecycle status */
unifiedFileStatus?: string;

View File

@@ -762,6 +762,7 @@ export class ExpertBlameService {
? new Date(workflow.lockedAt as string).toISOString()
: null,
type: String(doc.type ?? "THIRD_PARTY"),
creationMethod: doc.creationMethod ? String(doc.creationMethod) : undefined,
blameStatus: String(doc.blameStatus ?? ""),
partiesInitialForms: {
firstParty: statementToFormKey(firstParty?.statement) ?? "",

View File

@@ -52,6 +52,12 @@ export class ClaimDetailV2ResponseDto {
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description: "How the blame file was initiated: IN_PERSON or LINK",
example: "IN_PERSON",
})
creationMethod?: string;
@ApiPropertyOptional({
description:
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
@@ -157,6 +163,16 @@ export class ClaimDetailV2ResponseDto {
})
fileMakerApprovalActorId?: string;
@ApiPropertyOptional({
description:
"Fanavaran claim reference. Returned only after the local claim reaches COMPLETED and Fanavaran has supplied at least one reference.",
example: { claimNo: 123456, claimId: 987654 },
})
fanavaran?: {
claimNo?: number;
claimId?: number;
};
@ApiPropertyOptional({
description:
"Slice of `claim.evaluation` exposed to the damage expert. " +

View File

@@ -49,6 +49,12 @@ export class ClaimListItemV2Dto {
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description: 'How the blame file was initiated: IN_PERSON or LINK',
example: 'IN_PERSON',
})
creationMethod?: string;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',

View File

@@ -629,6 +629,7 @@ export class ExpertClaimService {
*/
private blameFileContextForExpert(blame: any): {
blameRequestType?: BlameRequestType;
creationMethod?: string;
carBodyFirstForm?: { car?: boolean; object?: boolean };
blameStatus?: string;
} {
@@ -636,9 +637,11 @@ export class ExpertClaimService {
const blameRequestType = blame.type as BlameRequestType;
const out: {
blameRequestType?: BlameRequestType;
creationMethod?: string;
carBodyFirstForm?: { car?: boolean; object?: boolean };
blameStatus?: string;
} = { blameRequestType };
if (blame.creationMethod) out.creationMethod = blame.creationMethod;
out.blameStatus = blame.blameStatus;
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
@@ -2220,10 +2223,16 @@ export class ExpertClaimService {
actorType: string;
},
metadata: Record<string, unknown>,
): Promise<void> {
): Promise<ClaimCaseStatus> {
// V5 retains the FileMaker approval gate, but no longer waits for a
// damaged-party final signature after that approval.
const completionStatus = (claimForTenant as any).requiresFileMakerApproval
? ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL
: ClaimCaseStatus.COMPLETED;
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
status: completionStatus,
claimStatus: ClaimStatus.APPROVED,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
@@ -2245,6 +2254,8 @@ export class ExpertClaimService {
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${claimRequestId}:factor_validation:${historyType}:${actor.sub}`,
});
return completionStatus;
}
/**
@@ -2417,7 +2428,7 @@ export class ExpertClaimService {
};
if (anyRejected) {
await this.completeClaimCaseAfterFactorValidationV2(
const completionStatus = await this.completeClaimCaseAfterFactorValidationV2(
claimRequestId,
claim,
actor,
@@ -2425,28 +2436,19 @@ export class ExpertClaimService {
historyActor,
{ replyField },
);
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
// Expertise (stage 4) is intentionally NOT submitted here.
// It is only triggered after the owner's final approval signature
// (submitOwnerInsurerApprovalSignV2), because until that point the
// expert review is not considered final — the user can still object.
return {
message: this.appendFanavaranAutoSubmitToMessage(
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
fanavaran,
),
message:
completionStatus === ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL
? "Factors were reviewed with expert repricing. The claim is awaiting FileMaker approval."
: "Factors were reviewed with expert repricing. The claim is completed without a final owner signature.",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
caseStatus: completionStatus,
outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
fanavaran,
};
}
await this.completeClaimCaseAfterFactorValidationV2(
const completionStatus = await this.completeClaimCaseAfterFactorValidationV2(
claimRequestId,
claim,
actor,
@@ -2455,25 +2457,15 @@ export class ExpertClaimService {
{ replyField },
);
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
// Expertise (stage 4) is intentionally NOT submitted here.
// It is only triggered after the owner's final approval signature
// (submitOwnerInsurerApprovalSignV2), because until that point the
// expert review is not considered final — the user can still object.
return {
message: this.appendFanavaranAutoSubmitToMessage(
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
fanavaran,
),
message:
completionStatus === ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL
? "All factors were approved. The claim is awaiting FileMaker approval."
: "All factors were approved by the expert. The claim is completed without a final owner signature.",
claimRequestId,
claimStatus: ClaimStatus.APPROVED,
caseStatus: ClaimCaseStatus.COMPLETED,
caseStatus: completionStatus,
outcome: "ALL_APPROVED_AUTO_COMPLETED",
fanavaran,
};
}
@@ -2555,11 +2547,21 @@ export class ExpertClaimService {
if (!blame) {
throw new NotFoundException("Linked blame file not found.");
}
if (!(blame as any).isMadeByFileMaker) {
const clientKey = requireActorClientKey(actor);
if (!blameCaseTouchesClient(blame, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
if (
!(blame as any).isMadeByFileMaker ||
!(blame as any).expertInitiated ||
(blame as any).creationMethod !== "IN_PERSON"
) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Only V4 FileMaker files can be assigned to a FileReviewer.",
message: "Only V4/V5 FileMaker files can be assigned to a FileReviewer.",
});
}
if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") {
@@ -2671,6 +2673,12 @@ export class ExpertClaimService {
if (!reviewerBlame) {
throw new NotFoundException("Linked blame file not found.");
}
const clientKey = requireActorClientKey(actor);
if (!blameCaseTouchesClient(reviewerBlame, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId)
: null;
@@ -3211,9 +3219,9 @@ export class ExpertClaimService {
* On success:
* - Stores reply; clears owner signature fields (`ownerInsurerApproval`, `ownerPricedPartsApproval`)
* - Unlocks the workflow
* - Pricing-only (`factorNeeded=false` everywhere): INSURER_REVIEW_AWAITING_OWNER_SIGN, APPROVED, INSURER_REVIEW → owner final sign/reject.
* - Pricing-only (`factorNeeded=false` everywhere): COMPLETED, APPROVED, CLAIM_COMPLETED — no final owner signature.
* - All lines `factorNeeded`: NEEDS_REVISION, OWNER_UPLOAD_FACTOR_DOCUMENTS → uploads → UNDER_REVIEW, EXPERT_COST_EVALUATION (validate factors).
* - Mixed priced + factor: NEEDS_REVISION, INSURER_REVIEW with next OWNER_UPLOAD_FACTOR_DOCUMENTS → owner signs priced lines first → upload factors → expert validates → final sign.
* - Mixed priced + factor: NEEDS_REVISION, INSURER_REVIEW with next OWNER_UPLOAD_FACTOR_DOCUMENTS → owner signs priced lines first → upload factors → expert validates → complete.
*/
async submitExpertReplyV2(
claimRequestId: string,
@@ -3379,14 +3387,23 @@ export class ExpertClaimService {
let currentStep = ClaimWorkflowStep.INSURER_REVIEW;
let nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED;
const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(processedParts);
const blameForFlow = claim.blameRequestId
? await this.blameRequestDbService.findById(String(claim.blameRequestId))
: undefined;
const skipsFinalOwnerSignature =
!!(blameForFlow as any)?.expertInitiated &&
(blameForFlow as any)?.creationMethod === "IN_PERSON";
const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(
processedParts,
skipsFinalOwnerSignature,
);
let nextClaimStatus = ClaimStatus.APPROVED;
// V5 flow: FileMaker must approve before the owner is asked to sign.
// When no factor upload is needed, skip the owner-sign step entirely here
// and park the claim at WAITING_FOR_FILE_MAKER_APPROVAL. The owner SMS
// and INSURER_REVIEW status are emitted by fileMakerApproveV5 instead.
// V5 retains a FileMaker approval gate after expert work. Expert-initiated
// in-person V2–V5 flows do not request a final damaged-party signature.
const isV5Claim = !!(claim as any).requiresFileMakerApproval;
const completesWithoutFactors =
!needsFactorUpload && !isV5Claim && skipsFinalOwnerSignature;
if (needsFactorUpload) {
nextClaimStatus = ClaimStatus.NEEDS_REVISION;
@@ -3399,7 +3416,10 @@ export class ExpertClaimService {
}
} else if (isV5Claim) {
// V5, no factors: hold at WAITING_FOR_FILE_MAKER_APPROVAL
currentStep = ClaimWorkflowStep.INSURER_REVIEW;
currentStep = ClaimWorkflowStep.CLAIM_COMPLETED;
nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED;
} else if (completesWithoutFactors) {
currentStep = ClaimWorkflowStep.CLAIM_COMPLETED;
nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED;
}
@@ -3417,10 +3437,11 @@ export class ExpertClaimService {
}
}
const updatePayload: Record<string, unknown> = {
status: isV5Claim && !needsFactorUpload
const persistedCaseStatus = isV5Claim && !needsFactorUpload
? ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL
: nextCaseStatus,
: nextCaseStatus;
const updatePayload: Record<string, unknown> = {
status: persistedCaseStatus,
claimStatus: nextClaimStatus,
...(expertAddedParts.length > 0
? { "damage.selectedParts": mergedSelectedParts }
@@ -3435,9 +3456,7 @@ export class ExpertClaimService {
"evaluation.ownerPricedPartsApproval": "",
},
"workflow.currentStep": currentStep,
"workflow.nextStep": needsFactorUpload
? nextWorkflowStep
: ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": nextWorkflowStep,
[`evaluation.${replyField}`]: replyPayload,
$push: {
"workflow.completedSteps": completedStep,
@@ -3476,30 +3495,36 @@ export class ExpertClaimService {
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
});
// V5: owner SMS is sent by fileMakerApproveV5 after FileMaker approval.
const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim);
if (ownerPhoneNotify && !needsFactorUpload && !isV5Claim) {
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: ownerPhoneNotify,
fileKind: "claim",
publicId: claim.publicId,
expertLastName,
link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v2"),
});
// V1 retains its existing final owner-signature notification. V2–V5 do not
// send this notification because their completion policy is manual Fanavaran
// submission after expert work (and FileMaker approval for V5).
if (!needsFactorUpload && !isV5Claim && !skipsFinalOwnerSignature) {
const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim);
if (ownerPhoneNotify) {
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: ownerPhoneNotify,
fileKind: "claim",
publicId: claim.publicId,
expertLastName,
link: this.smsOrchestrationService.buildClaimLink(
String(claim._id),
"v2",
),
});
}
}
// Fanavaran expertise is now triggered on owner final sign, not here.
// V2–V5 completion and FileMaker approval do not trigger a Fanavaran
// submission; the expert submits manually when ready.
return {
claimRequestId,
status: nextCaseStatus,
status: persistedCaseStatus,
claimStatus: nextClaimStatus,
currentStep,
workflowNextStep: needsFactorUpload
? nextWorkflowStep
: ClaimWorkflowStep.CLAIM_COMPLETED,
workflowNextStep: nextWorkflowStep,
factorNeeded: needsFactorUpload,
mixedPricingAndFactors: mixedFactorAndPrice,
allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines,
@@ -4017,7 +4042,7 @@ export class ExpertClaimService {
blameIds.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type parties expert.decision blameStatus status isMadeByFileMaker" },
{ lean: true, select: "type creationMethod parties expert.decision blameStatus status isMadeByFileMaker" },
)) as any[])
: [];
const blameById = new Map<string, any>(
@@ -4117,7 +4142,7 @@ export class ExpertClaimService {
blameIds.length > 0
? ((await this.blameRequestDbService.find(
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
{ lean: true, select: "type parties expert.decision blameStatus status" },
{ lean: true, select: "type creationMethod parties expert.decision blameStatus status" },
)) as any[])
: [];
const blameById = new Map<string, any>(
@@ -4212,7 +4237,7 @@ export class ExpertClaimService {
{
lean: true,
select:
"_id type parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
"_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval",
},
)) as any[];
@@ -4303,7 +4328,7 @@ export class ExpertClaimService {
const makerBlames = (await this.blameRequestDbService.find(
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
{ lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
{ lean: true, select: "_id type creationMethod parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" },
)) as any[];
if (makerBlames.length === 0) {
@@ -4770,6 +4795,12 @@ export class ExpertClaimService {
"FileReviewers can only access V4/V5 FileMaker files.",
);
}
const clientKey = requireActorClientKey(actor);
if (!blameCaseTouchesClient(linkedBlame, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
? String((linkedBlame as any).assignedFileReviewerId)
: null;
@@ -5110,6 +5141,14 @@ export class ExpertClaimService {
fileMakerApprovalActorId: (claim as any).fileMakerApprovalActorId
? String((claim as any).fileMakerApprovalActorId)
: undefined,
fanavaran:
claim.status === ClaimCaseStatus.COMPLETED &&
(claim.claimNo != null || claim.claimId != null)
? {
claimNo: claim.claimNo,
claimId: claim.claimId,
}
: undefined,
evaluation:
(evaluationForApi as
| ClaimDetailV2ResponseDto["evaluation"]

View File

@@ -166,7 +166,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Get claim request detail for damage expert",
description:
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Completed claims include Fanavaran `claimNo` / `claimId` when available. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
})
@ApiParam({ name: "claimRequestId" })
async getClaimDetailV2(
@@ -270,7 +270,7 @@ export class ExpertClaimV2Controller {
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
"- **No factors:** **`INSURER_REVIEW_AWAITING_OWNER_SIGN`**, `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" +
"- **No factors (V2–V5):** **`COMPLETED`**, `claimStatus=APPROVED`, `workflow.currentStep=CLAIM_COMPLETED` → no final owner signature. The expert can submit to Fanavaran manually when ready. For V5, the case instead waits for FileMaker approval before becoming `COMPLETED`.\n\n" +
"**Legacy rows** may still use `WAITING_FOR_INSURER_APPROVAL` instead of the specific values above.\n\n" +
"**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.",
})
@@ -352,8 +352,8 @@ export class ExpertClaimV2Controller {
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
"**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature.\n" +
"- **Any rejected (repriced):** same auto-complete for now (owner acceptance may be added later).\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +
"- **Partial batch:** returns pending until every factor line has a non-pending decision (no cap error until the batch is complete).",
})
@ApiParam({ name: "claimRequestId" })

View File

@@ -5,6 +5,25 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
// ---------------------------------------------------------------------------
export type ExpertKindFilter = "all" | "expert" | "damage_expert";
export type ReportRangeMode = "preset" | "custom";
export class InsurerReportAppliedRangeDto {
@ApiProperty({ enum: ["preset", "custom"] })
mode: ReportRangeMode;
@ApiProperty({ description: "Effective range start (ISO string)" })
from: string;
@ApiProperty({ description: "Effective range end (ISO string)" })
to: string;
@ApiPropertyOptional({
description:
"Preset day window used when the caller did not provide a full explicit from/to range.",
enum: [30, 60, 90],
})
periodDays?: 30 | 60 | 90;
}
export class InsurerReportQueryDto {
@ApiPropertyOptional({ description: "Start of date range (ISO string)" })
@@ -13,6 +32,13 @@ export class InsurerReportQueryDto {
@ApiPropertyOptional({ description: "End of date range (ISO string)" })
to?: string;
@ApiPropertyOptional({
enum: [30, 60, 90],
description:
"Preset reporting window in days. Default: 30. Ignored when both from and to are provided.",
})
periodDays?: number;
@ApiPropertyOptional({
enum: ["all", "expert", "damage_expert"],
description:
@@ -29,74 +55,93 @@ export class InsurerWorkLogQueryDto extends InsurerReportQueryDto {
limit?: number;
}
// ---------------------------------------------------------------------------
// Shared expert rows / counts
// ---------------------------------------------------------------------------
export class InsurerExpertCaseBreakdownDto {
@ApiProperty({ description: "All files in scope for this row/summary within the selected range" })
totalFiles: number;
@ApiProperty({ description: "Reviewed/handled files in scope for this row/summary" })
reviewedFiles: number;
@ApiProperty({ description: "Unreviewed/not-yet-handled files in scope for this row/summary" })
unreviewedFiles: number;
@ApiProperty({ description: "Third-party files in scope for this row/summary" })
thirdPartyFiles: number;
@ApiProperty({ description: "Car-body files in scope for this row/summary" })
carBodyFiles: number;
@ApiProperty({ description: "Reviewed third-party files in scope for this row/summary" })
thirdPartyReviewedFiles: number;
@ApiProperty({ description: "Unreviewed third-party files in scope for this row/summary" })
thirdPartyUnreviewedFiles: number;
@ApiProperty({ description: "Reviewed car-body files in scope for this row/summary" })
carBodyReviewedFiles: number;
@ApiProperty({ description: "Unreviewed car-body files in scope for this row/summary" })
carBodyUnreviewedFiles: number;
}
export class InsurerExpertListItemDto extends InsurerExpertCaseBreakdownDto {
@ApiProperty()
_id: string;
@ApiProperty()
fullName: string;
@ApiProperty({ enum: ["blame", "claim"] })
expertKind: "blame" | "claim";
@ApiProperty()
role: string;
@ApiPropertyOptional()
type?: string;
@ApiPropertyOptional({ description: "ISO creation datetime of the expert account" })
createdAt?: string | Date | null;
}
export class InsurerExpertsListResponseDto {
@ApiProperty({ type: InsurerReportAppliedRangeDto })
range: InsurerReportAppliedRangeDto;
@ApiProperty({ description: "Total experts in roster before pagination" })
total: number;
@ApiProperty({ description: "Current page (1-based)" })
page: number;
@ApiProperty({ description: "Requested page size" })
countPerPage: number;
@ApiProperty({ type: [InsurerExpertListItemDto] })
experts: InsurerExpertListItemDto[];
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
export class InsurerStatisticsBreakdownDto {
@ApiProperty({ description: "Files that have an insurer rating" })
filesWithInsurerRating: number;
@ApiProperty({ description: "Files that have a bot rating" })
filesWithBotRating: number;
@ApiProperty({ description: "Files that have any user rating" })
filesWithUserRating: number;
@ApiProperty({ description: "Files that have an objection" })
filesWithObjection: number;
@ApiProperty({ description: "Average insurer rating (0–5)" })
averageInsurerRating: number;
@ApiProperty({ description: "Average bot rating (0–5)" })
averageBotRating: number;
}
export class InsurerStatisticsDto {
@ApiProperty({
description:
"تعداد کل پرونده‌های بررسی‌شده — distinct tenant claim files with at least one expert CHECKED activity.",
})
totalFilesReviewed: number;
export class InsurerStatisticsDto extends InsurerExpertCaseBreakdownDto {
@ApiProperty({ type: InsurerReportAppliedRangeDto })
range: InsurerReportAppliedRangeDto;
@ApiProperty({
description:
"رضایت کاربران از روند پرونده — average of progressSpeed + registrationEase + overallEvaluation across rated claim files, normalised to 0–100. Formula: (avg of three dimensions across all rated files / 5) * 100.",
"Number of active experts in the selected range (experts with at least one attributed file in that range).",
})
averageUserRatingPercentage: number;
@ApiProperty({
description:
"پرونده‌های همراه — blame files where expertInitiated === true AND creationMethod === IN_PERSON.",
})
inPersonAccompaniedCount: number;
@ApiProperty({ description: "Claim files created in the current calendar month." })
filesCreatedThisMonth: number;
@ApiProperty({ description: "Total claim files scoped to this insurer." })
totalFiles: number;
@ApiProperty({
description:
"Percentage of total claim files that have any user rating recorded. (NOT a satisfaction metric — do not render as رضایت کاربران.)",
deprecated: true,
})
filesWithUserRatingPercentage: number;
@ApiProperty({ description: "Percentage of files with an objection." })
objectionPercentage: number;
@ApiProperty({ description: "Insurer-to-bot rating ratio (0–100)." })
insurerToBotRatingPercentage: number;
@ApiProperty({ type: InsurerStatisticsBreakdownDto })
breakdown: InsurerStatisticsBreakdownDto;
activeExperts: number;
}
// ---------------------------------------------------------------------------
// Top files
// Legacy top files (kept for backward compatibility; not used by the new reports)
// ---------------------------------------------------------------------------
export class TopFileUserRatingDto {
@@ -125,7 +170,7 @@ export class InsurerTopFileDto {
// Top experts
// ---------------------------------------------------------------------------
export class InsurerTopExpertDto {
export class InsurerTopExpertDto extends InsurerExpertCaseBreakdownDto {
@ApiProperty()
_id: string;
@@ -134,24 +179,81 @@ export class InsurerTopExpertDto {
@ApiProperty({ enum: ["blame", "claim"] })
expertKind: "blame" | "claim";
@ApiPropertyOptional({ description: "Overall average combined rating (0–5)" })
overallAverageRating: number | null;
@ApiProperty({ description: "Activity stats from expertFileActivity log" })
requestStats: { totalHandled: number; totalChecked: number };
}
export class InsurerTopExpertsDto {
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top blame-panel experts" })
blameExperts: InsurerTopExpertDto[];
@ApiProperty({ type: InsurerReportAppliedRangeDto })
range: InsurerReportAppliedRangeDto;
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top damage experts (claim)" })
claimExperts: InsurerTopExpertDto[];
@ApiProperty({ type: [InsurerTopExpertDto], description: "Top 10 experts by total file count" })
experts: InsurerTopExpertDto[];
}
// ---------------------------------------------------------------------------
// Work-log (mirrors reports.dto.ts shapes; exposed from this module for swagger)
// Charts
// ---------------------------------------------------------------------------
export class InsurerExpertChartPointDto {
@ApiProperty()
expertId: string;
@ApiProperty()
fullName: string;
@ApiProperty({ enum: ["blame", "claim"] })
expertKind: "blame" | "claim";
@ApiProperty()
value: number;
}
export class InsurerFilesByDayPointDto {
@ApiProperty({ description: "UTC date key (YYYY-MM-DD)" })
date: string;
@ApiProperty()
totalFiles: number;
@ApiProperty()
thirdPartyFiles: number;
@ApiProperty()
carBodyFiles: number;
}
export class InsurerAverageHandlingTimeByDayPointDto {
@ApiProperty({ description: "UTC date key (YYYY-MM-DD) based on handled/completed day" })
date: string;
@ApiProperty({ description: "Number of handled files included in the average for that day" })
handledFiles: number;
@ApiProperty({ description: "Average handling time in whole/civil days, rounded to 2 decimals" })
averageHandlingDays: number;
}
export class InsurerChartsDto {
@ApiProperty({ type: InsurerReportAppliedRangeDto })
range: InsurerReportAppliedRangeDto;
@ApiProperty({ type: [InsurerTopExpertDto] })
topExperts: InsurerTopExpertDto[];
@ApiProperty({ type: [InsurerExpertChartPointDto] })
filesPerExpertThirdParty: InsurerExpertChartPointDto[];
@ApiProperty({ type: [InsurerExpertChartPointDto] })
filesPerExpertCarBody: InsurerExpertChartPointDto[];
@ApiProperty({ type: [InsurerFilesByDayPointDto] })
filesByDay: InsurerFilesByDayPointDto[];
@ApiProperty({ type: [InsurerAverageHandlingTimeByDayPointDto] })
averageHandlingTimeByDay: InsurerAverageHandlingTimeByDayPointDto[];
}
// ---------------------------------------------------------------------------
// Work-log (legacy; kept exposed from this module for swagger)
// ---------------------------------------------------------------------------
export class InsurerWorkLogEntryDto {
@@ -172,12 +274,15 @@ export class InsurerWorkLogEntryDto {
@ApiProperty({
description:
"Distinct files with at least one CHECKED event in the reporting window (all-time when no date range given; restricted to from–to otherwise).",
"Distinct files with at least one CHECKED event in the reporting window (restricted to the effective selected range).",
})
distinctFilesCheckedInPeriod: number;
}
export class InsurerWorkLogResponseDto {
@ApiProperty({ type: InsurerReportAppliedRangeDto })
range: InsurerReportAppliedRangeDto;
@ApiProperty({ type: [InsurerWorkLogEntryDto] })
experts: InsurerWorkLogEntryDto[];

View File

@@ -40,6 +40,8 @@ import {
CreateFileReviewerByInsurerDto,
} from "./dto/create-insurer-expert.dto";
import {
InsurerChartsDto,
InsurerExpertsListResponseDto,
InsurerStatisticsDto,
InsurerTopExpertsDto,
InsurerTopFileDto,
@@ -148,13 +150,30 @@ export class ExpertInsurerController {
@ApiQuery({ name: "page", type: Number })
@ApiQuery({ name: "response_count", type: Number })
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({
name: "periodDays",
required: false,
enum: [30, 60, 90],
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
})
@ApiResponse({ status: 200, type: InsurerExpertsListResponseDto })
@Get("experts/list")
async getAllExperts(
@Query("page") page: number,
@Query("response_count") count: number,
@Query("from") from: string | undefined,
@Query("to") to: string | undefined,
@Query("periodDays") periodDays: string | undefined,
@CurrentUser() actor,
) {
return await this.expertInsurerService.retrieveAllExpertsOfClient(actor, page, count);
return await this.expertInsurerService.retrieveAllExpertsOfClient(
actor,
page,
count,
{ from, to, periodDays },
);
}
// ─── Reports: statistics ──────────────────────────────────────────────────
@@ -163,64 +182,91 @@ export class ExpertInsurerController {
@ApiOperation({
summary: "نمایش کلی — KPI cards for insurer reports page",
description:
"Returns `totalFilesReviewed` (distinct tenant claim files with ≥1 expert CHECKED activity), " +
"`averageUserRatingPercentage` (mean of progressSpeed + registrationEase + overallEvaluation across rated files, normalised to 0–100), " +
"`inPersonAccompaniedCount` (blame files where expertInitiated=true AND creationMethod=IN_PERSON), " +
"`filesCreatedThisMonth`, `totalFiles`, `objectionPercentage`, `insurerToBotRatingPercentage`. " +
"⚠️ `userRatingPercentage` and `filesWithUserRatingPercentage` are the share of files *that have any user rating* — NOT a satisfaction score. Use `averageUserRatingPercentage` for رضایت کاربران.",
"Returns the four main KPIs for the selected reporting range: total third-party files, total car-body files, total files, and active experts. " +
"Also returns reviewed/unreviewed totals with third-party/car-body breakdown using the same range logic as the expert table and charts.",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string) — restricts counted claim portfolio" })
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({
name: "periodDays",
required: false,
enum: [30, 60, 90],
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
})
@ApiResponse({ status: 200, type: InsurerStatisticsDto })
async getExpertStatistics(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("periodDays") periodDays?: string,
) {
return await this.expertInsurerService.getExpertStatisticsReport(actor, { from, to });
return await this.expertInsurerService.getExpertStatisticsReport(actor, {
from,
to,
periodDays,
});
}
// ─── Reports: top files ───────────────────────────────────────────────────
@Get("top-files")
@ApiOperation({
summary: "Top 10 highest-rated claim files for this insurer",
summary: "[Legacy] Top 10 highest-rated claim files for this insurer",
description:
"Sorted by combined insurer + user rating blend (getCombinedFileScore). " +
"Returns slim DTO only: publicId, createdAt, combinedScore, userRating.{comment, overallEvaluation}. " +
"Use publicId to build مشاهده پرونده links.",
"Legacy rating-based report endpoint. It is kept for backward compatibility but is no longer needed for the new insurer KPI/reporting page.",
deprecated: true,
})
@ApiQuery({ name: "from", required: false, description: "Restrict to files created on or after this ISO date" })
@ApiQuery({ name: "to", required: false, description: "Restrict to files created on or before this ISO date" })
@ApiQuery({
name: "periodDays",
required: false,
enum: [30, 60, 90],
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
})
@ApiResponse({ status: 200, type: [InsurerTopFileDto] })
async getTopFiles(
@CurrentUser() insurer,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("periodDays") periodDays?: string,
) {
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, { from, to });
return await this.expertInsurerService.getTopFilesForClient(insurer.clientKey, {
from,
to,
periodDays,
});
}
// ─── Reports: top experts (canonical + alias) ─────────────────────────────
@Get("experts/top")
@ApiOperation({
summary: "Top blame vs claim experts for this insurer",
summary: "Top 10 experts by file count for this insurer",
description:
"Response: `{ blameExperts: [], claimExperts: [] }`. " +
"Each item: `_id`, `fullName`, `expertKind`, `overallAverageRating`, `requestStats`. " +
"Sorted by overallAverageRating descending; up to 10 per group. " +
"Optional `from` / `to` accepted for forward-compat (currently accepted but roster is full-history).",
"Returns a single top-10 expert list ranked by total attributed files inside the selected reporting range. " +
"Each row includes reviewed/unreviewed and third-party/car-body breakdowns.",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({
name: "periodDays",
required: false,
enum: [30, 60, 90],
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
})
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
async getTopExperts(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("periodDays") periodDays?: string,
) {
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
return await this.expertInsurerService.getTopExpertsForClient(actor, {
from,
to,
periodDays,
});
}
/**
@@ -229,18 +275,52 @@ export class ExpertInsurerController {
*/
@Get("top-experts")
@ApiOperation({
summary: "Alias for GET experts/top — top blame + claim experts",
summary: "Alias for GET experts/top — top 10 experts by file count",
description: "Same response as `GET experts/top`. Provided for front-end compatibility.",
})
@ApiQuery({ name: "from", required: false })
@ApiQuery({ name: "to", required: false })
@ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] })
@ApiResponse({ status: 200, type: InsurerTopExpertsDto })
async getTopExpertsAlias(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("periodDays") periodDays?: string,
) {
return await this.expertInsurerService.getTopExpertsForClient(actor, { from, to });
return await this.expertInsurerService.getTopExpertsForClient(actor, {
from,
to,
periodDays,
});
}
@Get("report/charts")
@ApiOperation({
summary: "Chart datasets for insurer reports",
description:
"Returns all chart-ready datasets for the selected reporting range: top 10 experts by total files, files per expert for third-party and car-body, daily file counts, and daily average handling time.",
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({
name: "periodDays",
required: false,
enum: [30, 60, 90],
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
})
@ApiResponse({ status: 200, type: InsurerChartsDto })
async getReportCharts(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("periodDays") periodDays?: string,
) {
return this.expertInsurerService.getReportCharts(actor, {
from,
to,
periodDays,
});
}
// ─── Reports: work-log (delegates to ReportsService) ─────────────────────
@@ -258,6 +338,12 @@ export class ExpertInsurerController {
@ApiQuery({ name: "expertKind", required: false, enum: ["all", "expert", "damage_expert"], description: "Filter by expert kind" })
@ApiQuery({ name: "from", required: false, description: "Start of date window (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "End of date window (ISO string)" })
@ApiQuery({
name: "periodDays",
required: false,
enum: [30, 60, 90],
description: "Preset reporting window. Default: 30 days when from/to are omitted.",
})
@ApiQuery({ name: "page", required: false, type: Number, description: "Page (1-based)" })
@ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page" })
@ApiResponse({ status: 200, type: InsurerWorkLogResponseDto })
@@ -266,6 +352,7 @@ export class ExpertInsurerController {
@Query("expertKind") expertKind?: "all" | "expert" | "damage_expert",
@Query("from") from?: string,
@Query("to") to?: string,
@Query("periodDays") periodDays?: string,
@Query("page") page?: string,
@Query("limit") limit?: string,
) {
@@ -273,6 +360,7 @@ export class ExpertInsurerController {
expertKind,
from,
to,
periodDays,
page: page ? parseInt(page, 10) : undefined,
limit: limit ? parseInt(limit, 10) : undefined,
});
@@ -309,7 +397,7 @@ export class ExpertInsurerController {
@ApiOperation({
summary: "Activity timeline for a case",
description:
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event includes localized `faLabel`, enriched `actor`/`actorName`, the specific `performedBy` role, plus `performedByCategory` and `performedByFaLabel` for UI rendering.",
})
async getFileTimeline(@CurrentUser() insurer, @Param("publicId") publicId: string) {
return await this.expertInsurerService.getFileTimeline(insurer.clientKey, publicId);
@@ -355,12 +443,19 @@ export class ExpertInsurerController {
})
@ApiQuery({ name: "from", required: false, description: "Optional start datetime (ISO string)" })
@ApiQuery({ name: "to", required: false, description: "Optional end datetime (ISO string)" })
@ApiQuery({ name: "periodDays", required: false, enum: [30, 60, 90] })
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("periodDays") periodDays?: string,
) {
return await this.expertInsurerService.getInsurerFileStatusCounts(actor, from, to);
return await this.expertInsurerService.getInsurerFileStatusCounts(
actor,
from,
to,
periodDays,
);
}
// ─── Expert detail ────────────────────────────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,11 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
// File creation
FILE_CREATED_BY_REGISTRAR: "پرونده توسط ثبت‌کننده ایجاد شد",
FILE_CREATED_BY_FIELD_EXPERT: "پرونده توسط کارشناس میدانی ایجاد شد",
FILE_CREATED_BY_CALL_CENTER: "پرونده توسط اپراتور کال‌سنتر ایجاد شد",
// Expert link / OTP flow
LINK_SENT: "لینک ارسال شد",
CALL_CENTER_LINK_SENT: "لینک توسط اپراتور کال‌سنتر ارسال شد",
PARTY_OTP_SENT: "کد تأیید ارسال شد",
PARTY_OTP_VERIFIED: "کد تأیید تأیید شد",
PARTY_OTPS_VERIFIED: "کدهای تأیید هر دو طرف تأیید شدند",
@@ -30,6 +32,7 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
// Confession / accident type
FIRST_BLAME_CONFESSION_SUBMITTED: "اقرار اولیه طرف اول ثبت شد",
CALL_CENTER_INQUIRY_COMPLETED: "استعلام کال‌سنتر تکمیل شد",
CAR_BODY_ACCIDENT_TYPE_SUBMITTED: "نوع تصادف بدنه خودرو ثبت شد",
AUTO_ADVANCED_TO_CAR_BODY_FORM: "پیشرفت خودکار به فرم بدنه خودرو",
AUTO_CONFESSION_SKIPPED: "مرحله اقرار به‌صورت خودکار رد شد",
@@ -41,6 +44,7 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
SECOND_PARTY_INVITED: "طرف دوم دعوت شد",
// Accident fields / expert in-person completion
ACCIDENT_FIELDS_SAVED: "اطلاعات تصادف ثبت شد",
ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES:
"اطلاعات تصادف ذخیره و به مرحله امضاها پیشرفت شد",
EXPERT_COMPLETED_CAR_BODY_FORM_V2: "کارشناس فرم بدنه خودرو را تکمیل کرد",
@@ -72,12 +76,15 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
V3_PARTY_VOICE_UPLOADED: "صدای طرف بارگذاری شد",
V3_PARTY_LOCATION_SAVED: "موقعیت مکانی طرف ذخیره شد",
V3_PARTY_DESCRIPTION_SAVED: "توضیحات طرف ذخیره شد",
V4_PARTY_DESCRIPTION_SAVED: "توضیحات طرف ذخیره شد",
V3_CAR_BODY_ACCIDENT_TYPE_SAVED: "نوع تصادف بدنه خودرو ذخیره شد",
// V5 blame steps
V5_BLAME_ACCIDENT_VIDEO_UPLOADED: "ویدیوی تصادف (نسخه ۵) بارگذاری شد",
V5_FILE_MAKER_APPROVED: "پرونده‌ساز پرونده را تأیید کرد",
V5_FILE_MAKER_REJECTED: "پرونده‌ساز پرونده را رد کرد",
V5_FILE_MAKER_REJECTED_BLAME_RESET:
"پرونده‌ساز پرونده را رد کرد و پرونده به مرحله قبل بازگردانده شد",
// ---------------------------------------------------------------------------
// Claim-phase events (recorded on ClaimCase.history)
@@ -102,6 +109,8 @@ export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
// Documents & media
DOCUMENT_UPLOADED: "مدرک بارگذاری شد",
VIDEO_CAPTURE_UPLOADED: "تصویر ویدیویی بارگذاری شد",
PART_CAPTURED: "تصویر قطعه آسیب‌دیده بارگذاری شد",
ANGLE_CAPTURED: "تصویر زاویه خودرو بارگذاری شد",
ALL_FACTORS_UPLOADED_PENDING_VALIDATION:
"تمام فاکتورها بارگذاری شدند و در انتظار تأیید هستند",

View File

@@ -0,0 +1,37 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { claimCaseStatusAfterExpertReplyV2 } from "./claim-v2-expert-reply-workflow";
describe("claimCaseStatusAfterExpertReplyV2", () => {
it("keeps V1 priced-only claims in their final owner-signature state", () => {
expect(
claimCaseStatusAfterExpertReplyV2([
{ factorNeeded: false },
{ factorNeeded: false },
]),
).toBe(ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN);
});
it("completes a V2–V5 priced-only claim without a final owner signature", () => {
expect(
claimCaseStatusAfterExpertReplyV2(
[{ factorNeeded: false }, { factorNeeded: false }],
true,
),
).toBe(ClaimCaseStatus.COMPLETED);
});
it("keeps factor-required claims in their factor collection workflow", () => {
expect(
claimCaseStatusAfterExpertReplyV2([{ factorNeeded: true }]),
).toBe(ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING);
});
it("keeps the priced-line acceptance gate for mixed factor claims", () => {
expect(
claimCaseStatusAfterExpertReplyV2([
{ factorNeeded: false },
{ factorNeeded: true },
]),
).toBe(ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING);
});
});

View File

@@ -95,11 +95,16 @@ export function claimIsAwaitingExpertFactorValidationV2(claim: {
);
}
export function claimCaseStatusAfterExpertReplyV2(parts: ClaimPricingPartLite[]): ClaimCaseStatus {
export function claimCaseStatusAfterExpertReplyV2(
parts: ClaimPricingPartLite[],
completeWithoutFinalOwnerSignature = false,
): ClaimCaseStatus {
const { mixedFactorAndPrice } = classifyV2ExpertPricingParts(parts);
const needsFactorUpload = parts.some((p) => p.factorNeeded === true);
if (!needsFactorUpload) {
return ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN;
return completeWithoutFinalOwnerSignature
? ClaimCaseStatus.COMPLETED
: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN;
}
if (mixedFactorAndPrice) {
return ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING;

View File

@@ -58,6 +58,89 @@ export class ReportsService {
return { fromDate, toDate };
}
private startOfDay(date: Date): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
0,
0,
0,
0,
);
}
private endOfDay(date: Date): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
23,
59,
59,
999,
);
}
private parseReportPeriodDays(value?: string | number): 30 | 60 | 90 {
const parsed = Number(value ?? 30);
if (parsed === 30 || parsed === 60 || parsed === 90) return parsed;
throw new BadRequestException("periodDays must be one of 30, 60, or 90");
}
private resolveReportDateRange(opts: {
from?: string;
to?: string;
periodDays?: string | number;
} = {}) {
const hasFrom = typeof opts.from === "string" && opts.from.trim().length > 0;
const hasTo = typeof opts.to === "string" && opts.to.trim().length > 0;
const periodDays = this.parseReportPeriodDays(opts.periodDays);
const now = new Date();
if (!hasFrom && !hasTo) {
const toDate = this.endOfDay(now);
const fromBase = new Date(toDate);
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
const fromDate = this.startOfDay(fromBase);
return { mode: "preset" as const, fromDate, toDate, periodDays };
}
let { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
if (!fromDate && toDate) {
const fromBase = new Date(toDate);
fromBase.setDate(fromBase.getDate() - (periodDays - 1));
fromDate = this.startOfDay(fromBase);
}
if (fromDate && !toDate) {
toDate = this.endOfDay(now);
}
if (!fromDate || !toDate) {
throw new BadRequestException("Could not resolve reporting date range");
}
if (fromDate > toDate) {
throw new BadRequestException("'from' must be before or equal to 'to'");
}
return { mode: "custom" as const, fromDate, toDate, periodDays };
}
private serializeReportRange(range: {
mode: "preset" | "custom";
fromDate: Date;
toDate: Date;
periodDays?: 30 | 60 | 90;
}) {
return {
mode: range.mode,
from: range.fromDate.toISOString(),
to: range.toDate.toISOString(),
...(range.periodDays ? { periodDays: range.periodDays } : {}),
};
}
private isInDateRange(
value: unknown,
fromDate?: Date,
@@ -442,41 +525,40 @@ export class ReportsService {
expertKind?: "all" | "expert" | "damage_expert";
from?: string;
to?: string;
periodDays?: string | number;
page?: number;
limit?: number;
} = {},
): Promise<{ experts: InsurerExpertWorkLogEntryDtoRs[]; total: number }> {
): Promise<{
range: { mode: "preset" | "custom"; from: string; to: string; periodDays?: 30 | 60 | 90 };
experts: InsurerExpertWorkLogEntryDtoRs[];
total: number;
}> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
// expertKind filter
const filteredRows =
!opts.expertKind || opts.expertKind === "all"
? expertRows
: expertRows.filter((r) => r.kind === opts.expertKind);
const now = new Date();
// When from/to supplied, distinctFilesCheckedInPeriod is restricted to that window;
// totalHandled and currentlyChecking are snapshot-at-'to' (or now when to is absent).
const { fromDate, toDate } = this.parseDateRange(opts.from, opts.to);
const cutoff = toDate ?? now;
const checkedFrom = fromDate ?? new Date(0);
const checkedTo = toDate ?? now;
const entries = this.buildWorkLogEntries(filteredRows, events, cutoff, {
from: checkedFrom,
to: checkedTo,
const range = this.resolveReportDateRange(opts);
const entries = this.buildWorkLogEntries(filteredRows, events, range.toDate, {
from: range.fromDate,
to: range.toDate,
});
// Optional pagination
const page = Number(opts.page) > 0 ? Number(opts.page) : 1;
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : entries.length;
const start = (page - 1) * limit;
const paged = entries.slice(start, start + limit);
return { experts: paged, total: entries.length };
return {
range: this.serializeReportRange(range),
experts: paged,
total: entries.length,
};
}
async getInsurerExpertWorkLogPerMonth(actor: {

View File

@@ -37,12 +37,13 @@ class FileMakerRejectDto {
* V5 FileMaker approval panel.
*
* After the full claim flow completes (FileReviewer does damage assessment via
* expert-claim APIs, user signs), the claim lands in WAITING_FOR_FILE_MAKER_APPROVAL.
* expert-claim APIs and any required factors are validated), the claim lands in
* WAITING_FOR_FILE_MAKER_APPROVAL.
* The FileMaker who created the blame file can then:
*
* approve → triggers fanavaran submission (claim → COMPLETED)
* approve → claim → COMPLETED; an expert submits to Fanavaran manually
* reject → sends claim back to WAITING_FOR_DAMAGE_EXPERT so the FileReviewer
* can re-lock, adjust pricing, and redo the user interaction
* can re-lock, adjust pricing, and redo the claim work
*
* All endpoints operate on `claimRequestId` (the claim case ID, not the blame ID).
* Use `GET v5/file-maker/blame-request-management/claim-id/:requestId` to obtain
@@ -65,9 +66,8 @@ export class FileMakerClaimApprovalV5Controller {
summary: "Approve the completed claim (FileMaker V5)",
description:
"Approves a claim that is in `WAITING_FOR_FILE_MAKER_APPROVAL` status. " +
"Moves the claim to `INSURER_REVIEW_AWAITING_OWNER_SIGN` and sends the owner an SMS " +
"with a signature link. Fanavaran submission is triggered automatically after the " +
"owner signs.",
"Moves the claim to `COMPLETED`. No final owner signature is requested and " +
"Fanavaran submission remains a manual expert action.",
})
async approve(
@Param("claimRequestId") claimRequestId: string,

View File

@@ -0,0 +1,56 @@
import { Types } from "mongoose";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service";
describe("RequestManagementService V5 FileMaker approval", () => {
it("completes the claim without notifying the owner for a final signature", async () => {
const fileMakerId = new Types.ObjectId();
const claimId = new Types.ObjectId();
const claimCaseDbService = {
findById: jest.fn().mockResolvedValue({
_id: claimId,
publicId: "CLM-V5",
status: ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL,
requiresFileMakerApproval: true,
fileMakerApprovalActorId: fileMakerId,
}),
findByIdAndUpdate: jest.fn().mockResolvedValue({}),
};
const service = new (RequestManagementService as any)(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
claimCaseDbService,
) as RequestManagementService;
const result = await service.fileMakerApproveV5(
{ sub: String(fileMakerId), role: RoleEnum.FILE_MAKER },
String(claimId),
);
expect(result.status).toBe(ClaimCaseStatus.COMPLETED);
expect(result.message).toContain("Submit it to Fanavaran manually");
expect(claimCaseDbService.findByIdAndUpdate).toHaveBeenCalledWith(
String(claimId),
expect.objectContaining({
$set: expect.objectContaining({
status: ClaimCaseStatus.COMPLETED,
requiresFileMakerApproval: false,
}),
}),
);
});
});

View File

@@ -86,8 +86,9 @@ export class FileReviewerBlameV4Controller {
@Get("my-files")
@ApiOperation({
summary: "List all blame files assigned to this FileReviewer",
description: "Returns all V4 FileMaker blame files that have been assigned to the authenticated FileReviewer.",
summary: "List available and assigned FileMaker blame files",
description:
"Returns V4 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
@@ -96,8 +97,9 @@ export class FileReviewerBlameV4Controller {
@Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get a single blame file assigned to this FileReviewer",
description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V4 blame file.",
summary: "Get one available or assigned FileMaker blame file",
description:
"Returns full detail for a V4 FileMaker blame file in this reviewer's insurer when it is still available to claim or is assigned to the authenticated FileReviewer.",
})
async getMyFileDetail(
@Param("requestId") requestId: string,
@@ -342,7 +344,7 @@ export class FileReviewerBlameV4Controller {
);
}
// ─── Owner signature on expert pricing ───────────────────────────────────────
// ─── Mixed-factor priced-line signature ──────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@@ -360,11 +362,12 @@ export class FileReviewerBlameV4Controller {
},
})
@ApiOperation({
summary: "Owner signature on expert pricing (V4 — FileReviewer acts on behalf of user)",
summary: "Priced-line acceptance before factor uploads (V4 — FileReviewer acts for user)",
description:
"FileReviewer submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the FileReviewer's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
"For mixed priced/factor claims only, FileReviewer records the damaged party's " +
"acceptance of priced lines before factor uploads. V2–V5 no longer require a final " +
"owner signature: they complete after expert work. The FileReviewer's identity is " +
"resolved to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {

View File

@@ -84,8 +84,9 @@ export class FileReviewerBlameV5Controller {
@Get("my-files")
@ApiOperation({
summary: "List all blame files assigned to this FileReviewer",
description: "Returns all V5 FileMaker blame files that have been assigned to the authenticated FileReviewer.",
summary: "List available and assigned FileMaker blame files",
description:
"Returns V5 FileMaker blame files in this reviewer's insurer: sealed files that are still available to claim, plus files already assigned to the authenticated FileReviewer.",
})
async getMyFiles(@CurrentUser() fileReviewer: any) {
return this.requestManagementService.getMyFileReviewerFiles(fileReviewer);
@@ -94,8 +95,9 @@ export class FileReviewerBlameV5Controller {
@Get("my-files/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get a single blame file assigned to this FileReviewer",
description: "Returns full detail — parties, workflow, expert fields, linked claim ID — for one V5 blame file.",
summary: "Get one available or assigned FileMaker blame file",
description:
"Returns full detail for a V5 FileMaker blame file in this reviewer's insurer when it is still available to claim or is assigned to the authenticated FileReviewer.",
})
async getMyFileDetail(
@Param("requestId") requestId: string,
@@ -340,7 +342,7 @@ export class FileReviewerBlameV5Controller {
);
}
// ─── Owner signature on expert pricing ───────────────────────────────────────
// ─── Mixed-factor priced-line signature ──────────────────────────────────────
@Put("claim-sign/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@@ -358,11 +360,12 @@ export class FileReviewerBlameV5Controller {
},
})
@ApiOperation({
summary: "Owner signature on expert pricing (V5 — FileReviewer acts on behalf of user)",
summary: "Priced-line acceptance before factor uploads (V5 — FileReviewer acts for user)",
description:
"FileReviewer submits the damaged party's signature during the final approval stage. " +
"Delegates to the same service method as the user sign endpoint; the FileReviewer's " +
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
"For mixed priced/factor claims only, FileReviewer records the damaged party's " +
"acceptance of priced lines before factor uploads. V2–V5 no longer require a final " +
"owner signature: they complete after expert work. The FileReviewer's identity is " +
"resolved to the claim owner via `resolveClaimEffectiveUserId`.",
})
@UseInterceptors(
FileInterceptor("sign", {

View File

@@ -0,0 +1,140 @@
import { Types } from "mongoose";
import { ExpertClaimService } from "src/expert-claim/expert-claim.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service";
describe("RequestManagementService FileReviewer inbox", () => {
const reviewerId = new Types.ObjectId();
const otherReviewerId = new Types.ObjectId();
const clientId = new Types.ObjectId();
const otherClientId = new Types.ObjectId();
const sealedFile = {
_id: new Types.ObjectId(),
publicId: "BLM-OPEN",
type: "THIRD_PARTY",
status: "WAITING_FOR_FILE_REVIEWER",
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
parties: [
{
role: "FIRST",
person: { clientId, userId: new Types.ObjectId() },
},
],
expert: { decision: { guiltyPartyId: new Types.ObjectId() } },
};
function createService(files: any[]) {
const blameRequestDbService = {
find: jest.fn().mockResolvedValue(files),
};
const service = new (RequestManagementService as any)(
undefined,
blameRequestDbService,
) as RequestManagementService;
return { service, blameRequestDbService };
}
it("lists a FileMaker-sealed, unassigned file for a reviewer in the same tenant", async () => {
const { service, blameRequestDbService } = createService([sealedFile]);
const result = await service.getMyFileReviewerFiles({
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
});
expect(result).toEqual([
expect.objectContaining({ _id: sealedFile._id, publicId: "BLM-OPEN" }),
]);
expect(blameRequestDbService.find).toHaveBeenCalledWith(
expect.objectContaining({
isMadeByFileMaker: true,
expertInitiated: true,
creationMethod: "IN_PERSON",
$or: expect.arrayContaining([
expect.objectContaining({ status: "WAITING_FOR_FILE_REVIEWER" }),
expect.objectContaining({ assignedFileReviewerId: reviewerId }),
]),
}),
);
});
it("does not list another tenant's open file or a file assigned to another reviewer", async () => {
const { service } = createService([
{
...sealedFile,
_id: new Types.ObjectId(),
parties: [{ role: "FIRST", person: { clientId: otherClientId } }],
},
{
...sealedFile,
_id: new Types.ObjectId(),
assignedFileReviewerId: otherReviewerId,
},
]);
const result = await service.getMyFileReviewerFiles({
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
});
expect(result).toEqual([]);
});
it("does not expose an open file's details to a reviewer from another tenant", async () => {
const { service } = createService([]);
(service as any).blameRequestDbService.findById = jest.fn().mockResolvedValue({
...sealedFile,
parties: [{ role: "FIRST", person: { clientId: otherClientId } }],
});
await expect(
service.getMyFileReviewerFileDetail(
{
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
},
String(sealedFile._id),
),
).rejects.toThrow("does not belong to your organization");
});
it("does not let a reviewer claim another tenant's file through its linked claim ID", async () => {
const blameRequestDbService = {
findById: jest.fn().mockResolvedValue({
...sealedFile,
parties: [{ role: "FIRST", person: { clientId: otherClientId } }],
}),
findOneAndUpdate: jest.fn(),
};
const expertClaimService = new (ExpertClaimService as any)(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
blameRequestDbService,
) as ExpertClaimService;
await expect(
(expertClaimService as any).assignFileReviewerToV4Blame(
String(new Types.ObjectId()),
{ blameRequestId: sealedFile._id },
{
sub: String(reviewerId),
role: RoleEnum.FILE_REVIEWER,
clientKey: String(clientId),
},
),
).rejects.toThrow("does not belong to your organization");
expect(blameRequestDbService.findOneAndUpdate).not.toHaveBeenCalled();
});
});

View File

@@ -109,6 +109,10 @@ import {
buildBlamePartyAccessOrConditions,
collectUserIdVariants,
} from "src/helpers/party-access-queries";
import {
blameCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import { resolveLinkedUserIdStrings } from "src/helpers/user-access-resolver";
import { normalizePlateText } from "src/utils/plate-normalizer/plate-normalizer.service";
@@ -135,6 +139,26 @@ export class RequestManagementService {
throw new BadRequestException(`Step ${stepKey} is not a party-scoped step`);
}
private resolveBlameHistoryActorType(actor: any): string {
switch (actor?.role) {
case RoleEnum.FILE_MAKER:
return "file_maker";
case RoleEnum.FILE_REVIEWER:
return "file_reviewer";
case RoleEnum.REGISTRAR:
return "registrar";
case RoleEnum.CALL_CENTER:
return "call_center";
case RoleEnum.DAMAGE_EXPERT:
return "damage_expert";
case RoleEnum.EXPERT:
return "expert";
case RoleEnum.FIELD_EXPERT:
default:
return "field_expert";
}
}
/**
* Reverse map: Fanavaran/Tejarat numeric letter code → Persian plate letter.
* Tejarat inquiry stores Plk2 as a numeric code (e.g. 12 → "م", 5 → "د").
@@ -4651,6 +4675,12 @@ export class RequestManagementService {
"FileReviewer can only access V4/V5 FileMaker files.",
);
}
const clientKey = requireActorClientKey(expert);
if (!blameCaseTouchesClient(req, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
if (
req.status !== CaseStatus.WAITING_FOR_FILE_REVIEWER &&
req.status !== CaseStatus.WAITING_FOR_EXPERT &&
@@ -4992,7 +5022,7 @@ export class RequestManagementService {
actor: {
actorId: expertId,
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: { creationMethod: dto.creationMethod, type: dto.type },
});
@@ -5093,7 +5123,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: { sentTo, template: "yara-field-expert-link" },
});
@@ -5279,8 +5309,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType:
expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: {
firstPartyVerified: true,
@@ -5375,7 +5404,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: { phoneNumber: phone },
} as any);
@@ -5516,7 +5545,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: { partyRole: role, phoneNumber: phone },
} as any);
@@ -5558,8 +5587,7 @@ export class RequestManagementService {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: { advancedTo: WorkflowStep.CAR_BODY_ACCIDENT_TYPE },
} as any);
@@ -5593,10 +5621,8 @@ export class RequestManagementService {
type: "AUTO_CONFESSION_SKIPPED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: {
reason:
@@ -5621,9 +5647,9 @@ export class RequestManagementService {
type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType:
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: {
phoneNumber: phone,
@@ -6116,6 +6142,7 @@ export class RequestManagementService {
requestNo: plain.requestNo,
publicId: plain.publicId,
type: plain.type,
creationMethod: plain.creationMethod,
status: plain.status,
blameStatus: plain.blameStatus,
workflow: plain.workflow,
@@ -6382,7 +6409,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: {},
} as any);
@@ -6588,7 +6615,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: { guiltyPartyPhoneNumber: formData.guiltyPartyPhoneNumber },
} as any);
@@ -6652,8 +6679,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType:
expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: {
hasLocation: true,
@@ -6713,7 +6739,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: { videoId: firstParty.evidence.videoId },
} as any);
@@ -6770,7 +6796,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: {},
} as any);
@@ -7000,7 +7026,7 @@ export class RequestManagementService {
actorId: new Types.ObjectId(String(expert.sub)),
actorName:
`${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: {
accidentWay: fields.accidentWay,
@@ -9064,7 +9090,7 @@ export class RequestManagementService {
actorId: new Types.ObjectId(actor.sub),
actorName:
`${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
timestamp: new Date(),
metadata: {
@@ -9903,7 +9929,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: { partyRole, accepted: isAccept },
} as any);
@@ -9970,7 +9996,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(String(expert.sub)),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: { accidentWay: fields.accidentWay },
} as any);
@@ -10088,11 +10114,11 @@ export class RequestManagementService {
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V3_BLAME_ACCIDENT_VIDEO_UPLOADED",
type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
metadata: { videoId: videoId ?? null },
} as any);
@@ -10117,7 +10143,7 @@ export class RequestManagementService {
actorId: new Types.ObjectId(expert.sub),
actorName:
`${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(expert),
},
timestamp: new Date(),
metadata: {
@@ -10231,7 +10257,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: { partyRole: role, voiceId: String((voiceDoc as any)._id) },
} as any);
@@ -10274,7 +10300,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: { partyRole: role, location: body },
} as any);
@@ -10308,7 +10334,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: { partyRole: role },
} as any);
@@ -10368,7 +10394,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: {
partyRole: role,
@@ -10424,7 +10450,7 @@ export class RequestManagementService {
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
actorType: "field_expert",
actorType: this.resolveBlameHistoryActorType(actor),
},
metadata: { car: body.car, object: body.object },
} as any);
@@ -10436,9 +10462,9 @@ export class RequestManagementService {
/**
* V5 variant of expertUploadBlameVideoV3.
* Same flow as V3/V4 but additionally marks the linked claim with
* `requiresFileMakerApproval: true` so that after the owner signs,
* the claim is held at WAITING_FOR_FILE_MAKER_APPROVAL rather than
* being auto-submitted to fanavaran.
* `requiresFileMakerApproval: true` so that after expert work completes,
* the claim is held at WAITING_FOR_FILE_MAKER_APPROVAL. FileMaker approval
* completes the claim; Fanavaran submission is manual.
*/
async expertUploadBlameVideoV5(
expert: any,
@@ -10586,7 +10612,7 @@ export class RequestManagementService {
videoId,
status: req.status,
message: file
? "Blame accident video uploaded. File is now in expert review queue. After the full claim flow completes and the owner signs, the FileMaker must approve before fanavaran submission."
? "Blame accident video uploaded. File is now in expert review queue. After the full claim flow completes, the FileMaker must approve before manual Fanavaran submission."
: "File completed. Claim is now ready for damage expert review.",
};
}
@@ -10599,7 +10625,7 @@ export class RequestManagementService {
* - claim.status === WAITING_FOR_FILE_MAKER_APPROVAL
* - actor is FILE_MAKER and is the original creator of the linked blame file
*
* On approval: triggers fanavaran submission and moves claim to COMPLETED.
* On approval: completes the claim. Fanavaran submission is manual.
*/
async fileMakerApproveV5(
fileMaker: any,
@@ -10643,16 +10669,14 @@ export class RequestManagementService {
metadata: {},
};
// V5 approval: move claim to INSURER_REVIEW_AWAITING_OWNER_SIGN so the
// owner can now sign. Clear requiresFileMakerApproval so that when the
// owner later signs and autoSubmitToFanavaranV2OnClaimCompleted runs, it
// does not re-intercept the claim as a pending V5 gate.
// V5 approval is the final V5 gate. The damaged-party final signature is
// no longer required, and Fanavaran submission is intentionally manual.
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
requiresFileMakerApproval: false,
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
@@ -10660,36 +10684,12 @@ export class RequestManagementService {
},
});
// Notify the owner that the claim is ready for their signature.
const notifyUserId = (claim as any).damagedPartyUserId ?? (claim as any).owner?.userId;
if (notifyUserId && claim.blameRequestId) {
const blame = await this.blameRequestDbService.findById(
String(claim.blameRequestId),
);
const ownerParty = (blame?.parties || []).find(
(p: any) =>
p?.person?.userId && String(p.person.userId) === String(notifyUserId),
);
const ownerPhone = ownerParty?.person?.phoneNumber
?? (await this.userDbService.findOne({ _id: new Types.ObjectId(String(notifyUserId)) }))?.mobile;
if (ownerPhone && typeof ownerPhone === "string") {
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: ownerPhone,
fileKind: "claim",
publicId: claim.publicId,
expertLastName: actorName.split(/\s+/).pop() || "کارشناس",
link: this.smsOrchestrationService.buildClaimLink(claimRequestId, "v2"),
});
}
}
return {
claimRequestId,
publicId: claim.publicId,
status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
status: ClaimCaseStatus.COMPLETED,
message:
"Claim approved by FileMaker. Owner has been notified to sign. " +
"Once the owner signs, the claim will be completed and submitted to Fanavaran.",
"Claim approved by FileMaker and completed. Submit it to Fanavaran manually when ready.",
};
}
@@ -11399,12 +11399,41 @@ export class RequestManagementService {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint.");
}
const clientKey = requireActorClientKey(fileReviewer);
const reviewerId = new Types.ObjectId(fileReviewer.sub);
const files = await this.blameRequestDbService.find({
isMadeByFileMaker: true,
assignedFileReviewerId: reviewerId,
expertInitiated: true,
creationMethod: CreationMethod.IN_PERSON,
$or: [
// A FileMaker-sealed file must be discoverable before a reviewer can
// claim it. Once another reviewer takes it, only that reviewer sees it.
{
status: CaseStatus.WAITING_FOR_FILE_REVIEWER,
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
{ assignedFileReviewerId: reviewerId },
],
});
return (files || []).map((f: any) => ({
const visibleFiles = (files || []).filter((file: any) => {
const assignedReviewerId = file.assignedFileReviewerId
? String(file.assignedFileReviewerId)
: null;
const isOpen =
file.status === CaseStatus.WAITING_FOR_FILE_REVIEWER &&
!assignedReviewerId;
const isAssignedToReviewer =
assignedReviewerId === String(fileReviewer.sub);
return (
blameCaseTouchesClient(file, clientKey) &&
(isOpen || isAssignedToReviewer)
);
});
return visibleFiles.map((f: any) => ({
_id: f._id,
publicId: f.publicId,
requestNo: f.requestNo,
@@ -11426,17 +11455,33 @@ export class RequestManagementService {
if (fileReviewer?.role !== RoleEnum.FILE_REVIEWER) {
throw new ForbiddenException("Only FileReviewers can use this endpoint.");
}
const clientKey = requireActorClientKey(fileReviewer);
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found");
if (!req.isMadeByFileMaker) {
if (
!req.isMadeByFileMaker ||
!req.expertInitiated ||
req.creationMethod !== CreationMethod.IN_PERSON
) {
throw new ForbiddenException("FileReviewer can only access V4/V5 FileMaker files.");
}
if (!blameCaseTouchesClient(req, clientKey)) {
throw new ForbiddenException(
"This file does not belong to your organization.",
);
}
const assignedId = (req as any).assignedFileReviewerId
? String((req as any).assignedFileReviewerId)
: null;
if (assignedId && assignedId !== String(fileReviewer.sub)) {
throw new ForbiddenException("This file has been taken by another FileReviewer.");
}
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) };

View File

@@ -2,6 +2,19 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Schema({ _id: false, versionKey: false })
export class FileMakerLocationModel {
@Prop({ type: String, required: true })
id: string;
@Prop({ type: String, required: true })
name: string;
}
export const FileMakerLocationDbSchema = SchemaFactory.createForClass(
FileMakerLocationModel,
);
@Schema({
collection: "file-maker",
versionKey: false,
@@ -31,6 +44,9 @@ export class FileMakerModel {
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
@Prop({ type: [FileMakerLocationDbSchema], default: [] })
locations?: FileMakerLocationModel[];
@Prop({ required: true })
password: string;
@@ -49,6 +65,12 @@ export class FileMakerModel {
@Prop({ type: "string", required: false })
expertCode?: string;
@Prop({ type: String, required: false })
ThirdPartyClaimExpertId?: string;
@Prop({ type: String, required: false })
CarBodyClaimExpertId?: string;
createdAt: Date;
}

View File

@@ -2,6 +2,19 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Schema({ _id: false, versionKey: false })
export class FileReviewerLocationModel {
@Prop({ type: String, required: true })
id: string;
@Prop({ type: String, required: true })
name: string;
}
export const FileReviewerLocationDbSchema = SchemaFactory.createForClass(
FileReviewerLocationModel,
);
@Schema({
collection: "file-reviewer",
versionKey: false,
@@ -31,6 +44,9 @@ export class FileReviewerModel {
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
@Prop({ type: [FileReviewerLocationDbSchema], default: [] })
locations?: FileReviewerLocationModel[];
@Prop({ required: true })
password: string;
@@ -49,6 +65,12 @@ export class FileReviewerModel {
@Prop({ type: "string", required: false })
expertCode?: string;
@Prop({ type: String, required: false })
ThirdPartyExpertiseClaim?: string;
@Prop({ type: String, required: false })
CarBodyExpertiseClaim?: string;
createdAt: Date;
}