forked from Yara724/api
Compare commits
27 Commits
c7d77d30c4
...
ebfeef9e01
| Author | SHA1 | Date | |
|---|---|---|---|
| ebfeef9e01 | |||
| cf74d75146 | |||
| f5a60eafb1 | |||
|
|
ae49031c55 | ||
| 23d636416d | |||
|
|
6880de5960 | ||
| 85bd892720 | |||
|
|
a33466025d | ||
| a5d2f5a2b9 | |||
|
|
226b63aefb | ||
| 5203c07154 | |||
|
|
6566b5f112 | ||
| 24a38a6780 | |||
|
|
dc3748ae94 | ||
| fe83c20e7e | |||
|
|
e5a28238e5 | ||
| 8cf7b7b229 | |||
|
|
e4ef95a23e | ||
| 5ec40f66dc | |||
|
|
f6f4429ce7 | ||
|
|
2df7a889d3 | ||
| 93edba412f | |||
|
|
3bd6bc6e9d | ||
|
|
26c80512ba | ||
| 85fc83b564 | |||
|
|
f09f5b79f0 | ||
|
|
bfbcfcab37 |
1506
docs/blame-claim-flow-architecture.fa.html
Normal file
1506
docs/blame-claim-flow-architecture.fa.html
Normal file
File diff suppressed because it is too large
Load Diff
1896
docs/blame-claim-flow-architecture.html
Normal file
1896
docs/blame-claim-flow-architecture.html
Normal file
File diff suppressed because it is too large
Load Diff
596
docs/external-integrations-reference.fa.html
Normal file
596
docs/external-integrations-reference.fa.html
Normal 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>
|
||||
593
docs/external-integrations-reference.html
Normal file
593
docs/external-integrations-reference.html
Normal 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 & 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>
|
||||
625
docs/panel-roles-reference.fa.html
Normal file
625
docs/panel-roles-reference.fa.html
Normal 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>
|
||||
617
docs/panel-roles-reference.html
Normal file
617
docs/panel-roles-reference.html
Normal 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 & 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
1422
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start: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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
464
scripts/data/parsian-tehran/file-makers.json
Normal file
464
scripts/data/parsian-tehran/file-makers.json
Normal file
@@ -0,0 +1,464 @@
|
||||
{
|
||||
"clientCode": 8,
|
||||
"fileMakers": [
|
||||
{
|
||||
"nationalCode": "0061077410",
|
||||
"mobile": "09125864643",
|
||||
"firstName": "احمد",
|
||||
"lastName": "بستان پيرا",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210110",
|
||||
"name": "شعبه پونک"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "32",
|
||||
"CarBodyClaimExpertId": "33"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0492009856",
|
||||
"mobile": "09128465836",
|
||||
"firstName": "اسداله",
|
||||
"lastName": "نجفي پور",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "91",
|
||||
"CarBodyClaimExpertId": "92"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0062277553",
|
||||
"mobile": "09125012274",
|
||||
"firstName": "اسماعيل",
|
||||
"lastName": "سلطانمحمدي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4221",
|
||||
"CarBodyClaimExpertId": "4222"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0492295557",
|
||||
"mobile": "09125409101",
|
||||
"firstName": "اکبر",
|
||||
"lastName": "بيگ محمدي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "111130",
|
||||
"name": "شعبه ويژه ميرداماد"
|
||||
},
|
||||
{
|
||||
"id": "110011",
|
||||
"name": "ستاد مرکزي"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "278"
|
||||
},
|
||||
{
|
||||
"nationalCode": "3220096573",
|
||||
"mobile": "09104933206",
|
||||
"firstName": "پدرام",
|
||||
"lastName": "حاتمي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "219"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0078954010",
|
||||
"mobile": "09128894315",
|
||||
"firstName": "پروانه",
|
||||
"lastName": "آدابي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "739",
|
||||
"CarBodyClaimExpertId": "740"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0075127105",
|
||||
"mobile": "09122938429",
|
||||
"firstName": "حميد",
|
||||
"lastName": "غوثي هوجقان",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "3215",
|
||||
"ThirdPartyClaimExpertId": "3214"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0081114494",
|
||||
"mobile": "09374439044",
|
||||
"firstName": "داود",
|
||||
"lastName": "صديق",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "222",
|
||||
"CarBodyClaimExpertId": "223"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0084130938",
|
||||
"mobile": "09392558640",
|
||||
"firstName": "رسول",
|
||||
"lastName": "کرکي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4662"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0014103788",
|
||||
"mobile": "09118861247",
|
||||
"firstName": "سجاد",
|
||||
"lastName": "حشمت",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4245",
|
||||
"CarBodyClaimExpertId": "4246"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0065938100",
|
||||
"mobile": "09122366860",
|
||||
"firstName": "سهيلا",
|
||||
"lastName": "شکوري قره چيق",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "89",
|
||||
"ThirdPartyClaimExpertId": "88"
|
||||
},
|
||||
{
|
||||
"nationalCode": "5779888949",
|
||||
"mobile": "09371290042",
|
||||
"firstName": "عاطفه",
|
||||
"lastName": "نصيري",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4455",
|
||||
"ThirdPartyClaimExpertId": "4454"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0069608210",
|
||||
"mobile": "09120766792",
|
||||
"firstName": "عباس",
|
||||
"lastName": "سلطاني محمدي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "742",
|
||||
"CarBodyClaimExpertId": "743"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0065409027",
|
||||
"mobile": "09125045732",
|
||||
"firstName": "علي",
|
||||
"lastName": "اصلاني حاجي آبادي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4338",
|
||||
"CarBodyClaimExpertId": "4339"
|
||||
},
|
||||
{
|
||||
"nationalCode": "4570007309",
|
||||
"mobile": "09104873190",
|
||||
"firstName": "علي",
|
||||
"lastName": "قرباني",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4540",
|
||||
"ThirdPartyClaimExpertId": "4539"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0077396881",
|
||||
"mobile": "09120000001",
|
||||
"firstName": "علي",
|
||||
"lastName": "مهرجو",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4652",
|
||||
"ThirdPartyClaimExpertId": "4650"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0068289782",
|
||||
"mobile": "09124196488",
|
||||
"firstName": "عليرضا",
|
||||
"lastName": "درگاهي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "57",
|
||||
"CarBodyClaimExpertId": "58"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0066868521",
|
||||
"mobile": "09129344240",
|
||||
"firstName": "عليرضا",
|
||||
"lastName": "گودرزي پور",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4663"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0061947466",
|
||||
"mobile": "09125045283",
|
||||
"firstName": "غزال",
|
||||
"lastName": "عطائي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4220",
|
||||
"ThirdPartyClaimExpertId": "4219"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0062500767",
|
||||
"mobile": "09124464022",
|
||||
"firstName": "غلامرضا",
|
||||
"lastName": "حسن پوراقدم",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4452",
|
||||
"ThirdPartyClaimExpertId": "4451"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0019675526",
|
||||
"mobile": "09120399833",
|
||||
"firstName": "فاطمه",
|
||||
"lastName": "عظيميان",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4340",
|
||||
"ThirdPartyClaimExpertId": "4341"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0056888082",
|
||||
"mobile": "09122406750",
|
||||
"firstName": "قاسم",
|
||||
"lastName": "نصراللهي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4664"
|
||||
},
|
||||
{
|
||||
"nationalCode": "3932143930",
|
||||
"mobile": "09125255267",
|
||||
"firstName": "محسن",
|
||||
"lastName": "کرمي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4415",
|
||||
"ThirdPartyClaimExpertId": "4414"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0013269755",
|
||||
"mobile": "09195502061",
|
||||
"firstName": "محمد",
|
||||
"lastName": "ابراهيمي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "4092",
|
||||
"ThirdPartyClaimExpertId": "4091"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0011834803",
|
||||
"mobile": "09127059125",
|
||||
"firstName": "محمد",
|
||||
"lastName": "ترابي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "1010",
|
||||
"CarBodyClaimExpertId": "1011"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0493217789",
|
||||
"mobile": "09126966943",
|
||||
"firstName": "مصطفي",
|
||||
"lastName": "محمدزاده قورقچي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4665"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0080501281",
|
||||
"mobile": "09356468730",
|
||||
"firstName": "موسي",
|
||||
"lastName": "موسي نژاد",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"CarBodyClaimExpertId": "745",
|
||||
"ThirdPartyClaimExpertId": "744"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0078209129",
|
||||
"mobile": "09126038117",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "شاملوفرد",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4666"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0074009141",
|
||||
"mobile": "09339103762",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "معمارباشي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "216",
|
||||
"CarBodyClaimExpertId": "217"
|
||||
},
|
||||
{
|
||||
"nationalCode": "3720211207",
|
||||
"mobile": "09379120944",
|
||||
"firstName": "مهسا",
|
||||
"lastName": "وطن نيا",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210110",
|
||||
"name": "شعبه پونک"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "34",
|
||||
"CarBodyClaimExpertId": "35"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0079616801",
|
||||
"mobile": "09379669839",
|
||||
"firstName": "مهيار",
|
||||
"lastName": "کيائي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "1004",
|
||||
"CarBodyClaimExpertId": "1005"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0079815278",
|
||||
"mobile": "09195883178",
|
||||
"firstName": "ميگل",
|
||||
"lastName": "ميرشکاري",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "4646",
|
||||
"CarBodyClaimExpertId": "4648"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0010736638",
|
||||
"mobile": "09355242492",
|
||||
"firstName": "ناصر",
|
||||
"lastName": "عيوضي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyClaimExpertId": "228",
|
||||
"CarBodyClaimExpertId": "229"
|
||||
}
|
||||
]
|
||||
}
|
||||
161
scripts/data/parsian-tehran/file-reviewers.json
Normal file
161
scripts/data/parsian-tehran/file-reviewers.json
Normal file
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"clientCode": 8,
|
||||
"fileReviewers": [
|
||||
{
|
||||
"nationalCode": "0051967839",
|
||||
"mobile": "09121354859",
|
||||
"firstName": "حسين",
|
||||
"lastName": "جعفري",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"ThirdPartyExpertiseClaim": "3542",
|
||||
"CarBodyExpertiseClaim": "3541"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0084130938",
|
||||
"mobile": "09392558640",
|
||||
"firstName": "رسول",
|
||||
"lastName": "کرکي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyExpertiseClaim": "29",
|
||||
"CarBodyExpertiseClaim": "28"
|
||||
},
|
||||
{
|
||||
"nationalCode": "1262982308",
|
||||
"mobile": "09130121246",
|
||||
"firstName": "روح الله",
|
||||
"lastName": "سلمانيان مقدم نياسري",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"CarBodyExpertiseClaim": "3705"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0066868521",
|
||||
"mobile": "09129344240",
|
||||
"firstName": "عليرضا",
|
||||
"lastName": "گودرزي پور",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"CarBodyExpertiseClaim": "15",
|
||||
"ThirdPartyExpertiseClaim": "16"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0440245151",
|
||||
"mobile": "09130606183",
|
||||
"firstName": "فرهاد",
|
||||
"lastName": "ملکي مونقي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "110011",
|
||||
"name": "ستاد مرکزي"
|
||||
}
|
||||
],
|
||||
"ThirdPartyExpertiseClaim": "60",
|
||||
"CarBodyExpertiseClaim": "61"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0056888082",
|
||||
"mobile": "09122406750",
|
||||
"firstName": "قاسم",
|
||||
"lastName": "نصراللهي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyExpertiseClaim": "4645"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0670358118",
|
||||
"mobile": "09124421539",
|
||||
"firstName": "مجيد",
|
||||
"lastName": "اميري",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210040",
|
||||
"name": "شعبه شرق تهران(210040)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyExpertiseClaim": "3985",
|
||||
"CarBodyExpertiseClaim": "3986"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0083730397",
|
||||
"mobile": "09125759960",
|
||||
"firstName": "مجيد",
|
||||
"lastName": "کاظمي دولت سرا",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210120",
|
||||
"name": "شعبه والفجر"
|
||||
}
|
||||
],
|
||||
"ThirdPartyExpertiseClaim": "3992",
|
||||
"CarBodyExpertiseClaim": "3991"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0493217789",
|
||||
"mobile": "09126966943",
|
||||
"firstName": "مصطفي",
|
||||
"lastName": "محمدزاده قورقچي",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
}
|
||||
],
|
||||
"ThirdPartyExpertiseClaim": "3545",
|
||||
"CarBodyExpertiseClaim": "3544"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0076988961",
|
||||
"mobile": "09108357378",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "روشن دل",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210110",
|
||||
"name": "شعبه پونک"
|
||||
}
|
||||
],
|
||||
"CarBodyExpertiseClaim": "3988",
|
||||
"ThirdPartyExpertiseClaim": "3989"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0078209129",
|
||||
"mobile": "09126038117",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "شاملوفرد",
|
||||
"locations": [
|
||||
{
|
||||
"id": "210050",
|
||||
"name": "شعبه غرب تهران(210050)"
|
||||
},
|
||||
{
|
||||
"id": "210110",
|
||||
"name": "شعبه پونک"
|
||||
}
|
||||
],
|
||||
"CarBodyExpertiseClaim": "73",
|
||||
"ThirdPartyExpertiseClaim": "72"
|
||||
}
|
||||
]
|
||||
}
|
||||
2427
scripts/seed-insurer-reports-fixtures.ts
Normal file
2427
scripts/seed-insurer-reports-fixtures.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
233
src/case-expert-report/case-expert-report.builder.spec.ts
Normal file
233
src/case-expert-report/case-expert-report.builder.spec.ts
Normal 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(
|
||||
"نیاز به تعویض سپر جلو",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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: "وضعیت آب و هوا",
|
||||
|
||||
59
src/claim-request-management/claim-details-access.v2.spec.ts
Normal file
59
src/claim-request-management/claim-details-access.v2.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
describe("V2 claim-detail access for split file roles", () => {
|
||||
const makerId = "maker-id";
|
||||
const reviewerId = "reviewer-id";
|
||||
|
||||
const createService = (blame: Record<string, unknown>) => {
|
||||
const service = Object.create(
|
||||
ClaimRequestManagementService.prototype,
|
||||
) as ClaimRequestManagementService;
|
||||
(service as any).blameRequestDbService = {
|
||||
findById: jest.fn().mockResolvedValue(blame),
|
||||
};
|
||||
return service;
|
||||
};
|
||||
|
||||
const claim = { blameRequestId: "blame-id" };
|
||||
|
||||
it("allows the FileMaker who created a completed V4/V5 file", async () => {
|
||||
const service = createService({
|
||||
isMadeByFileMaker: true,
|
||||
expertInitiated: true,
|
||||
creationMethod: "IN_PERSON",
|
||||
initiatedByFieldExpertId: makerId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).assertActorCanViewClaimV2(claim, makerId, {
|
||||
sub: makerId,
|
||||
role: RoleEnum.FILE_MAKER,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows only the assigned FileReviewer", async () => {
|
||||
const service = createService({
|
||||
isMadeByFileMaker: true,
|
||||
expertInitiated: true,
|
||||
creationMethod: "IN_PERSON",
|
||||
assignedFileReviewerId: reviewerId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).assertActorCanViewClaimV2(claim, reviewerId, {
|
||||
sub: reviewerId,
|
||||
role: RoleEnum.FILE_REVIEWER,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
(service as any).assertActorCanViewClaimV2(claim, "other-reviewer", {
|
||||
sub: "other-reviewer",
|
||||
role: RoleEnum.FILE_REVIEWER,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ?? "",
|
||||
|
||||
@@ -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. " +
|
||||
|
||||
@@ -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`)',
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"تمام فاکتورها بارگذاری شدند و در انتظار تأیید هستند",
|
||||
|
||||
|
||||
37
src/helpers/claim-v2-expert-reply-workflow.spec.ts
Normal file
37
src/helpers/claim-v2-expert-reply-workflow.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
src/request-management/file-maker-claim-approval-v5.spec.ts
Normal file
56
src/request-management/file-maker-claim-approval-v5.spec.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
140
src/request-management/request-management.file-reviewer.spec.ts
Normal file
140
src/request-management/request-management.file-reviewer.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user