From 406b139b3d8c022f34ab75e894a00dad39060b00 Mon Sep 17 00:00:00 2001
From: SepehrYahyaee <7heycallmegray@gmail.com>
Date: Sat, 19 Sep 2026 12:11:43 +0330
Subject: [PATCH] fix: align inquiry errors and expert review rules
---
...xpert-claim-reply-submit-v2-frontend.fa.md | 14 ++--
docs/external-integrations-reference.fa.html | 2 +-
docs/external-integrations-reference.html | 2 +-
docs/panel-roles-reference.fa.html | 6 +-
docs/panel-roles-reference.html | 6 +-
src/common/utils/inquiry-error.spec.ts | 54 +++++++++++-
src/common/utils/inquiry-error.ts | 47 ++++++++---
src/constants/repair-amount-limits.ts | 4 +-
.../expert-blame.participants.spec.ts | 66 +++++++++++++++
src/expert-blame/expert-blame.service.ts | 12 ++-
src/expert-claim/dto/claim-detail-v2.dto.ts | 12 ++-
src/expert-claim/expert-claim.service.spec.ts | 82 +++++++++++++++++++
src/expert-claim/expert-claim.service.ts | 50 ++++++++---
.../expert-claim.v2.controller.ts | 4 +-
src/helpers/claim-price-cap.spec.ts | 25 ++++++
src/helpers/claim-price-cap.ts | 39 +++++++++
.../inquiry-participant-resolver.spec.ts | 20 +++++
.../inquiry-participant-resolver.ts | 4 +-
...st-management.policyholder-inquiry.spec.ts | 28 +++++++
.../request-management.service.ts | 9 ++
src/sand-hub/sand-hub.service.spec.ts | 26 ++++--
21 files changed, 457 insertions(+), 55 deletions(-)
create mode 100644 src/expert-blame/expert-blame.participants.spec.ts
create mode 100644 src/helpers/claim-price-cap.spec.ts
create mode 100644 src/helpers/claim-price-cap.ts
diff --git a/docs/expert-claim-reply-submit-v2-frontend.fa.md b/docs/expert-claim-reply-submit-v2-frontend.fa.md
index a4c452c..6d6ecf5 100644
--- a/docs/expert-claim-reply-submit-v2-frontend.fa.md
+++ b/docs/expert-claim-reply-submit-v2-frontend.fa.md
@@ -56,7 +56,7 @@ Content-Type: application/json
| `typeOfDamage` | بله | دقیقاً یکی از `"تعمیر"` یا `"تعویض"`. |
| `price` | برای `"تعویض"` بله؛ برای `"تعمیر"` اختیاری | در صورت ارسال، مبلغ بین 100,000 تا 10,000,000,000 تومان. |
| `salary` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. |
-| `totalPayment` | بله | مبلغ بین 100,000 تا 10,000,000,000 تومان. مجموع این فیلدها در کل پرونده نباید از 53,000,000 تومان بیشتر شود. |
+| `totalPayment` | بله | مبلغ بین 1,000,000 تا 100,000,000,000 ریال. فقط در جریان V1، مجموع این فیلدها در کل پرونده نباید از 530,000,000 ریال بیشتر شود. |
| `factorNeeded` | بله | مقدار Boolean واقعی (`true` یا `false`)؛ رشته ارسال نکنید. |
| `daghi` | برای `"تعویض"` بله | برای `"تعمیر"` لازم نیست و در ثبت نهایی حذف میشود. |
| `daghi.option` | در صورت وجود `daghi` بله | یکی از `"ارزش لوازم بازیافتی"`، `"تحویل داغی"`، `"فاقد ارزش"` یا `"با احتساب داغی"`. |
@@ -66,7 +66,7 @@ Content-Type: application/json
نکتهها:
- مقدار `0` برای هیچ مبلغ ارسالی این endpoint معتبر نیست؛ حداقل مبلغ 100,000 تومان است.
-- محدودیت 10 میلیارد مربوط به **هر فیلد مبلغ** است؛ سقف 53 میلیون مربوط به **مجموع `totalPayment` تمام ردیفها** است و همچنان اعمال میشود.
+- محدودیت هر فیلد مبلغ مستقل است؛ سقف 530 میلیون ریال فقط برای **مجموع `totalPayment` تمام ردیفهای پرونده V1** اعمال میشود. جریانهای V2 تا V6 سقف مجموع ندارند.
- فیلدهای ناشناخته در body حذف میشوند. فرانتاند نباید برای انتقال داده به آنها تکیه کند.
## پاسخ موفق
@@ -152,15 +152,15 @@ Content-Type: application/json
```json
{
"statusCode": 400,
- "message": "مجموع مبلغ قطعات (۵۴٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳٬۰۰۰٬۰۰۰) تومان بیشتر است.",
+ "message": "مجموع مبلغ قطعات (۵۴۰٬۰۰۰٬۰۰۰) از سقف مجاز (۵۳۰٬۰۰۰٬۰۰۰) ریال بیشتر است.",
"error": "PRICE_CAP_ERROR",
"code": "PRICE_CAP_ERROR",
- "totalPrice": 54000000,
- "priceCap": 53000000
+ "totalPrice": 540000000,
+ "priceCap": 530000000
}
```
-این خطا به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمتها نمایش دهید و در صورت نیاز از `priceCap` برای پیام UI استفاده کنید.
+این خطا فقط برای پروندههای V1 رخ میدهد و به یک ردیف مشخص وصل نیست. آن را در بالای جدول قیمتها نمایش دهید و از `priceCap` جزئیات پرونده/خطا برای پیام UI استفاده کنید. مقدار `priceCap: null` یعنی پرونده سقف مجموع ندارد.
### 4. خطاهای قواعد پرونده و workflow
@@ -225,4 +225,4 @@ function applySubmitError(body: ApiErrorBody) {
}
```
-قبل از ارسال، فرانتاند میتواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. برای جلوگیری از خطای سقف مجموع، جمع `totalPayment` ردیفها را نیز پیش از ارسال محاسبه و نمایش دهید.
+قبل از ارسال، فرانتاند میتواند همین بازه مبلغ را برای تجربه کاربری بهتر بررسی کند؛ با این حال اعتبار نهایی همیشه پاسخ API است. جمع `totalPayment` را فقط وقتی `priceCap` جزئیات پرونده عدد است با سقف مقایسه کنید؛ `null` یعنی V2 تا V6 و بدون سقف مجموع.
diff --git a/docs/external-integrations-reference.fa.html b/docs/external-integrations-reference.fa.html
index 1626b01..6a07fda 100644
--- a/docs/external-integrations-reference.fa.html
+++ b/docs/external-integrations-reference.fa.html
@@ -364,7 +364,7 @@
| POST | /inquiry/sheba | اعتبارسنجی شبا / حساب بانکی. |
- ESG هر پاسخ را به صورت { success: boolean, data: … } میپیچد. یک بدنه success=false به یک خطای فارسی "استعلام در دسترس نیست" ترجمه میشود.
+ ESG هر پاسخ را به صورت { success: boolean, data: … } میپیچد. در envelope نرمالشده خطا، بکاند مقدار error.messageFa را بدون تغییر به فراخواننده برمیگرداند؛ فیلدهای فنی مانند message، providerMessage و providerCode برای ثبت لاگ و دستهبندی حفظ میشوند. خطای کسبوکاری «یافت نشد» بهعنوان قطعی سرویس گزارش نمیشود.
بررسی داده آفلاین-پرسوجو هنوز ابتدا اجرا میشود، قبل از هر فراخوانی HTTP ESG.
diff --git a/docs/external-integrations-reference.html b/docs/external-integrations-reference.html
index ce00df3..8c7eefd 100644
--- a/docs/external-integrations-reference.html
+++ b/docs/external-integrations-reference.html
@@ -361,7 +361,7 @@
| POST | /inquiry/sheba | Sheba / bank account validation. |
- ESG wraps every response as { success: boolean, data: … }. A success=false body is translated to a contextual Persian error. For example, موردی یافت نشد becomes a plate- or VIN-specific “no matching policy” message; it is not reported as a provider outage.
+ ESG wraps every response as { success: boolean, data: … }. For normalized error envelopes, the backend returns error.messageFa unchanged to the caller; technical fields such as message, providerMessage, and providerCode remain available for logging and classification. A business-level not-found response is not reported as a provider outage.
The offline-inquiry seed check still runs first, before any ESG HTTP call.
diff --git a/docs/panel-roles-reference.fa.html b/docs/panel-roles-reference.fa.html
index b23c284..831a321 100644
--- a/docs/panel-roles-reference.fa.html
+++ b/docs/panel-roles-reference.fa.html
@@ -370,14 +370,14 @@
| متد | مسیر | توضیح |
| GET | v2/expert-claim/requests | فهرست خسارتها در صف WAITING_FOR_DAMAGE_EXPERT + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType. |
- | GET | v2/expert-claim/request/:claimRequestId | جزئیات کامل خسارت: قطعات آسیبدیده، تصاویر گرفتهشده، اسناد، priceDrop، داده طرف بلیم، آدرسهای ویدیو. |
+ | GET | v2/expert-claim/request/:claimRequestId | جزئیات کامل خسارت، بههمراه priceCap مؤثر: ۵۳۰٬۰۰۰٬۰۰۰ ریال برای V1 و null برای V2 تا V6 یا وقتی سقف غیرفعال است. |
| POST | v2/expert-claim/assign/:claimRequestId | قفل خسارت برای این کارشناس. بازمیگرداند: assigned، already_assigned_to_you، یا ۴۰۹. |
| GET | v2/expert-claim/request/:claimRequestId/price-drop | محتوای کاهش قیمت: برچسبهای شدت، کاتالوگ ضریب، قطعات آسیبدیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر. |
| PUT | v2/expert-claim/request/:claimRequestId/price-drop | محاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰. |
- | PUT | v2/expert-claim/reply/submit/:claimRequestId | ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمتگذاریشده، داغی، branchId). سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان. بسته به پرچمهای factorNeeded، خسارت را به owner-sign، mixed-factors-pending، یا صف اعتبارسنجی فاکتور منتقل میکند. |
+ | PUT | v2/expert-claim/reply/submit/:claimRequestId | ارسال پاسخ ارزیابی خسارت (لیست قطعات قیمتگذاریشده، داغی، branchId). فقط V1: کل ≤ ۵۳۰٬۰۰۰٬۰۰۰ ریال؛ V2 تا V6 بدون سقف مجموع هستند. |
| PUT | v2/expert-claim/reply/resend/:claimRequestId | درخواست از کاربر برای ارسال مجدد اسناد/عکسها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمیگرداند. |
| PATCH | v2/expert-claim/:claimRequestId/visit | درخواست از کاربر برای مراجعه حضوری. خسارت را آزاد میکند، وضعیت claimStatus را به NEEDS_REVISION تنظیم میکند. |
- | PATCH | v2/expert-claim/validate-factors/:claimRequestId | اعتبارسنجی فاکتورهای تعمیرگاه بارگذاریشده. تأیید یا رد هر خط فاکتور با totalPayment. سقف برای تمام خطوط اعمال میشود (≤ ۵۳،۰۰۰،۰۰۰ تومان). پس از تصمیمگیری درباره تمام خطوط، بهصورت خودکار تکمیل میشود. |
+ | PATCH | v2/expert-claim/validate-factors/:claimRequestId | اعتبارسنجی فاکتورهای تعمیرگاه بارگذاریشده. سقف ۵۳۰٬۰۰۰٬۰۰۰ ریال تمام خطوط فقط برای V1 اعمال میشود؛ V2 تا V6 بدون سقف هستند. |
| PATCH | v2/expert-claim/request/:claimRequestId/damaged-parts | ویرایش قطعات آسیبدیده انتخابشده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING). |
| GET | v2/expert-claim/outer-parts-catalog | کاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر). |
| GET | v2/expert-claim/inner-parts-catalog | JSON ثابت کاتالوگ قطعات داخلی خودرو. |
diff --git a/docs/panel-roles-reference.html b/docs/panel-roles-reference.html
index adedc62..0c490e9 100644
--- a/docs/panel-roles-reference.html
+++ b/docs/panel-roles-reference.html
@@ -363,14 +363,14 @@
| Method | Route | What it does |
| GET | v2/expert-claim/requests | List claims in WAITING_FOR_DAMAGE_EXPERT queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType. |
- | GET | v2/expert-claim/request/:claimRequestId | Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs. Completed claims include Fanavaran claimNo / claimId when available. |
+ | GET | v2/expert-claim/request/:claimRequestId | Full claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs, and the effective priceCap (530,000,000 Rial for V1; null for V2–V6 or when disabled). Completed claims include Fanavaran claimNo / claimId when available. |
| POST | v2/expert-claim/assign/:claimRequestId | Lock claim to this expert. Returns assigned, already_assigned_to_you, or 409. |
| GET | v2/expert-claim/request/:claimRequestId/price-drop | Price-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry. |
| PUT | v2/expert-claim/request/:claimRequestId/price-drop | Calculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400. |
- | PUT | v2/expert-claim/reply/submit/:claimRequestId | Submit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی option. Cap: total ≤ 53 000 000 Toman. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission. |
+ | PUT | v2/expert-claim/reply/submit/:claimRequestId | Submit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی option. V1 only: total ≤ 530,000,000 Rial; V2–V6 are uncapped. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission. |
| PUT | v2/expert-claim/reply/resend/:claimRequestId | Request user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled. |
| PATCH | v2/expert-claim/:claimRequestId/visit | Ask user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION. |
- | PATCH | v2/expert-claim/validate-factors/:claimRequestId | 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. |
+ | PATCH | v2/expert-claim/validate-factors/:claimRequestId | Validate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. The 530,000,000 Rial all-lines cap applies only to V1. Auto-completes when all lines are decided. |
| PATCH | v2/expert-claim/request/:claimRequestId/damaged-parts | Edit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING). |
| GET | v2/expert-claim/outer-parts-catalog | Fanavaran outer car-components catalog (shared with user flow). |
| GET | v2/expert-claim/inner-parts-catalog | Static inner car-parts catalog JSON. |
diff --git a/src/common/utils/inquiry-error.spec.ts b/src/common/utils/inquiry-error.spec.ts
index 4ea2920..cce24da 100644
--- a/src/common/utils/inquiry-error.spec.ts
+++ b/src/common/utils/inquiry-error.spec.ts
@@ -4,13 +4,63 @@ import {
} from "./inquiry-error";
describe("inquiry error messages", () => {
- it("turns ESG not-found responses into a contextual plate message", () => {
+ it("preserves a Persian ESG not-found response", () => {
expect(
getInquiryErrorMessage(
{ success: false, message: "موردی یافت نشد" },
"thirdPartyPlate",
),
- ).toBe("بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.");
+ ).toBe("موردی یافت نشد");
+ });
+
+ it.each([
+ ["RECORD_NOT_FOUND", "رکوردی یافت نشد", "Provider request failed"],
+ [
+ "INQUIRY_NO_MATCH",
+ "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
+ "Inquiry returned no matching result",
+ ],
+ ])(
+ "prefers ESG messageFa for %s over the technical message",
+ (code, messageFa, message) => {
+ expect(
+ getInquiryErrorMessage(
+ {
+ error: {
+ code,
+ message,
+ messageFa,
+ providerMessage: message,
+ providerCode: code,
+ },
+ attemptSummary: {
+ attempts: [{ code, message, messageFa }],
+ },
+ },
+ "thirdPartyPlate",
+ ),
+ ).toBe(messageFa);
+ },
+ );
+
+ it("finds messageFa inside an HTTP response envelope", () => {
+ expect(
+ getInquiryErrorMessage(
+ {
+ response: {
+ status: 404,
+ data: {
+ error: {
+ code: "RECORD_NOT_FOUND",
+ message: "Provider request failed",
+ messageFa: "رکوردی یافت نشد",
+ },
+ },
+ },
+ },
+ "thirdPartyPlate",
+ ),
+ ).toBe("رکوردی یافت نشد");
});
it("distinguishes VIN and car-body not-found failures", () => {
diff --git a/src/common/utils/inquiry-error.ts b/src/common/utils/inquiry-error.ts
index 2f08ee8..08ce261 100644
--- a/src/common/utils/inquiry-error.ts
+++ b/src/common/utils/inquiry-error.ts
@@ -87,7 +87,25 @@ export function inquiryErrorStatus(error: unknown): number | undefined {
}
export function extractInquiryProviderMessage(error: unknown): string {
- for (const record of errorRecords(error)) {
+ const records = errorRecords(error);
+
+ // The normalized ESG/Parsian envelope carries the safe user-facing text in
+ // messageFa while `message` and `providerMessage` may remain technical.
+ // Search every envelope level for that explicit Persian field before
+ // considering generic message fields on an outer object.
+ for (const record of records) {
+ for (const key of [
+ "messageFa",
+ "MessageFa",
+ "messageFA",
+ "persianMessage",
+ ] as const) {
+ const message = cleanMessage(record[key]);
+ if (message) return message;
+ }
+ }
+
+ for (const record of records) {
for (const key of ["message", "Message", "detail", "title"] as const) {
const message = cleanMessage(record[key]);
if (message) return message;
@@ -128,13 +146,21 @@ export function isInquiryTimeout(error: unknown): boolean {
const hasPersian = (value: string): boolean => /[\u0600-\u06ff]/.test(value);
const isNotFound = (error: unknown, message: string): boolean => {
- const root = asRecord(error);
- const responseData = asRecord(asRecord(root?.response)?.data);
- const code = String(root?.code ?? responseData?.code ?? "").toUpperCase();
+ const codes = errorRecords(error).flatMap((record) =>
+ [record.code, record.providerCode]
+ .map((code) => String(code ?? "").toUpperCase())
+ .filter(Boolean),
+ );
return (
inquiryErrorStatus(error) === 404 ||
- ["NOT_FOUND", "POLICY_NOT_FOUND", "NO_POLICY", "RECORD_NOT_FOUND"].includes(
- code,
+ codes.some((code) =>
+ [
+ "NOT_FOUND",
+ "POLICY_NOT_FOUND",
+ "NO_POLICY",
+ "RECORD_NOT_FOUND",
+ "INQUIRY_NO_MATCH",
+ ].includes(code),
) ||
/\bnot[ -]?found\b|\bno (?:active |relevant )?(?:record|policy|item)\b|record\.not\.found|موردی یافت نشد|یافت نشد|پیدا نشد|فاقد بیمه(?:| )?نامه/i.test(
message,
@@ -175,6 +201,11 @@ export function getInquiryErrorMessage(
): string {
const providerMessage = extractInquiryProviderMessage(error);
+ // Persian text supplied by the provider is already the intended client
+ // message. Preserve it verbatim instead of replacing it with a local
+ // contextual fallback such as "inquiry not found".
+ if (providerMessage && hasPersian(providerMessage)) return providerMessage;
+
if (isNotFound(error, providerMessage)) return NOT_FOUND_MESSAGES[context];
if (
@@ -208,10 +239,6 @@ export function getInquiryErrorMessage(
return "سرویس استعلام در دسترس نیست. لطفاً کمی بعد دوباره تلاش کنید.";
}
- // A provider's specific Persian validation/business message is already safe
- // and more useful than replacing it with a broad local validation message.
- if (providerMessage && hasPersian(providerMessage)) return providerMessage;
-
if (isInvalidInput(providerMessage) || inquiryErrorStatus(error) === 422) {
return INVALID_MESSAGES[context];
}
diff --git a/src/constants/repair-amount-limits.ts b/src/constants/repair-amount-limits.ts
index a9941eb..56d5665 100644
--- a/src/constants/repair-amount-limits.ts
+++ b/src/constants/repair-amount-limits.ts
@@ -8,13 +8,13 @@ export const REPAIR_LINE_AMOUNT_TOMAN = { // IT IS RIAL FROM NOW ON
MAX: 530_000_000,
} as const;
-/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
+/** Max sum of all priced + factor lines in one V1 expert reply / validation (Rial). */
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
/**
- * Returns null when the claim v2 total cap is disabled.
+ * Returns null when the V1 claim total cap is disabled.
*
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
diff --git a/src/expert-blame/expert-blame.participants.spec.ts b/src/expert-blame/expert-blame.participants.spec.ts
new file mode 100644
index 0000000..8ca8b4d
--- /dev/null
+++ b/src/expert-blame/expert-blame.participants.spec.ts
@@ -0,0 +1,66 @@
+import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
+import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
+import { ExpertBlameService } from "./expert-blame.service";
+
+describe("ExpertBlameService participant detail contract", () => {
+ it("returns normalized participants and their role assignments from the blame party", async () => {
+ const service = new (ExpertBlameService as any)(
+ ...new Array(13).fill(undefined),
+ ) as any;
+ const expertId = "66ec0e480e321873c0900001";
+
+ service.expireBlameCaseWorkflowLockV2IfStale = jest
+ .fn()
+ .mockResolvedValue(undefined);
+ service.blameRequestDbService = {
+ findByIdWithoutHistory: jest.fn().mockResolvedValue({
+ _id: "66ec0e480e321873c0900002",
+ type: BlameRequestType.THIRD_PARTY,
+ status: CaseStatus.WAITING_FOR_EXPERT,
+ expertInitiated: true,
+ initiatedByFieldExpertId: expertId,
+ workflow: {},
+ parties: [
+ {
+ role: "FIRST",
+ person: { fullName: "Legacy Party Name" },
+ participants: [
+ {
+ participantId: "PERSON_1",
+ nationalCode: "0012345678",
+ birthday: "1370/01/01",
+ unknown: true,
+ },
+ ],
+ participantRoles: {
+ driver: "PERSON_1",
+ vehicleOwner: "PERSON_1",
+ thirdPartyPolicyholder: "PERSON_1",
+ },
+ vehicle: { inquiry: { raw: { large: true } } },
+ },
+ ],
+ createdAt: new Date("2026-09-19T00:00:00.000Z"),
+ updatedAt: new Date("2026-09-19T00:00:00.000Z"),
+ }),
+ };
+
+ const result = await service.findOneV2("blame-1", { sub: expertId });
+ const party = (result.parties as any[])[0];
+
+ expect(party.participants).toEqual([
+ {
+ participantId: "PERSON_1",
+ nationalCode: "0012345678",
+ birthday: "1370/01/01",
+ },
+ ]);
+ expect(party.participantRoles).toEqual({
+ driver: "PERSON_1",
+ vehicleOwner: "PERSON_1",
+ thirdPartyPolicyholder: "PERSON_1",
+ });
+ expect(party.person.fullName).toBe("Legacy Party Name");
+ expect(party.vehicle.inquiry).toBeUndefined();
+ });
+});
diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts
index b682124..e840672 100644
--- a/src/expert-blame/expert-blame.service.ts
+++ b/src/expert-blame/expert-blame.service.ts
@@ -91,6 +91,7 @@ import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
+import { sanitizeStoredInquiryParticipants } from "src/request-management/inquiry-participant-resolver";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -1250,8 +1251,17 @@ export class ExpertBlameService {
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
+ // Keep the normalized blame-party participant contract authoritative for
+ // every expert detail response. Legacy person fields remain for old files,
+ // but new UIs must read participants + participantRoles from the same party.
+ doc.parties = parties.map((party: Record) => ({
+ ...party,
+ participants: sanitizeStoredInquiryParticipants(party.participants),
+ participantRoles: party.participantRoles,
+ }));
+
// Strip heavy SandHub inquiry blob
- for (const party of parties as Array<{
+ for (const party of doc.parties as Array<{
vehicle?: Record;
}>) {
if (
diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts
index 2b827e7..8221ce6 100644
--- a/src/expert-claim/dto/claim-detail-v2.dto.ts
+++ b/src/expert-claim/dto/claim-detail-v2.dto.ts
@@ -53,11 +53,19 @@ export class ClaimDetailV2ResponseDto {
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
- description: "How the blame file was initiated: IN_PERSON or LINK",
+ description: "How the blame file was initiated: NORMAL, IN_PERSON, or LINK",
example: "IN_PERSON",
})
creationMethod?: string;
+ @ApiProperty({
+ nullable: true,
+ description:
+ "Maximum expert-reply total for V1 user-created files. Null for V2-V6 flows or when the cap is disabled.",
+ example: 530000000,
+ })
+ priceCap: number | null;
+
@ApiPropertyOptional({
description:
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
@@ -278,7 +286,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
- "Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
+ "Linked blame case (`blameCases`), same shape as expert-blame detail. Each party exposes its authoritative normalized `participants` and `participantRoles`, plus video/voice URLs, workflow, expert, and formatted dates.",
})
blameCase?: Record;
diff --git a/src/expert-claim/expert-claim.service.spec.ts b/src/expert-claim/expert-claim.service.spec.ts
index 96e443c..8ab91c8 100644
--- a/src/expert-claim/expert-claim.service.spec.ts
+++ b/src/expert-claim/expert-claim.service.spec.ts
@@ -168,6 +168,88 @@ describe("ExpertClaimService expert-reply pricing", () => {
);
});
+ it("enforces the configured total-payment cap for a V1 user-created file", async () => {
+ const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
+
+ try {
+ const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
+ BlameRequestType.THIRD_PARTY,
+ );
+ (service as any).blameRequestDbService.findById.mockResolvedValue({
+ type: BlameRequestType.THIRD_PARTY,
+ creationMethod: "NORMAL",
+ });
+
+ await expect(
+ service.submitExpertReplyV2(
+ "v1-claim",
+ {
+ ...validV2Reply,
+ parts: [
+ {
+ ...validV2Reply.parts[0],
+ salary: "1000000",
+ totalPayment: "600000000",
+ },
+ ],
+ },
+ {
+ sub: V2_EXPERT_ID,
+ fullName: "Expert One",
+ role: RoleEnum.FIELD_EXPERT,
+ },
+ ),
+ ).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } });
+
+ expect(findByIdAndUpdate).not.toHaveBeenCalled();
+ } finally {
+ if (previousEnabled === undefined) {
+ delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ } else {
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
+ }
+ }
+ });
+
+ it("does not enforce the V1 cap for an expert-initiated flow", async () => {
+ const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true";
+
+ try {
+ const { service, findByIdAndUpdate } = setupSuccessfulV2Submit(
+ BlameRequestType.THIRD_PARTY,
+ );
+
+ await service.submitExpertReplyV2(
+ "v3-claim",
+ {
+ ...validV2Reply,
+ parts: [
+ {
+ ...validV2Reply.parts[0],
+ salary: "1000000",
+ totalPayment: "600000000",
+ },
+ ],
+ },
+ {
+ sub: V2_EXPERT_ID,
+ fullName: "Expert One",
+ role: RoleEnum.FIELD_EXPERT,
+ },
+ );
+
+ expect(findByIdAndUpdate).toHaveBeenCalledTimes(1);
+ } finally {
+ if (previousEnabled === undefined) {
+ delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED;
+ } else {
+ process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled;
+ }
+ }
+ });
+
it("allows a repair line without daghi and removes a stray daghi payload", () => {
const service = createService() as any;
diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts
index 2d1d9aa..40e6753 100644
--- a/src/expert-claim/expert-claim.service.ts
+++ b/src/expert-claim/expert-claim.service.ts
@@ -156,7 +156,7 @@ import {
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
-/** Maximum sum of line `totalPayment` across the claim (Toman; priced parts + factor lines after validation). */
+/** Configured V1 maximum sum of line `totalPayment` across the claim (Rial). */
import { getClaimV2TotalPaymentCapToman } from "src/constants/repair-amount-limits";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
@@ -180,6 +180,7 @@ import {
normalizeMoneyAmountString,
parseMoneyAmountToman,
} from "src/utils/unicode-digits";
+import { claimPriceCapAppliesToBlame } from "src/helpers/claim-price-cap";
@Injectable()
export class ExpertClaimService {
@@ -1694,8 +1695,13 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
- // Validate total price cap (priced lines sum), when enabled.
- const priceCap = getClaimV2TotalPaymentCapToman();
+ // The total cap is a V1-only rule. Legacy claims embed their blame file.
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null &&
+ claimPriceCapAppliesToBlame(request.blameFile)
+ ? configuredPriceCap
+ : null;
if (priceCap !== null && reply.parts && reply.parts.length > 0) {
let totalPrice = 0;
@@ -1727,7 +1733,7 @@ export class ExpertClaimService {
if (totalPrice > priceCap) {
throw new BadRequestException({
- message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
+ message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
@@ -2293,7 +2299,8 @@ export class ExpertClaimService {
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
* — All approved → COMPLETED + APPROVED (expert-entered line totals; no extra owner signature).
* — Any rejected (repriced) → COMPLETED + APPROVED (auto-close for now; owner sign may be added later).
- * When enabled by env, total of all repair lines must be ≤ the claim v2 total payment cap.
+ * For V1 user-created files, when enabled by env, the total of all repair
+ * lines must be no greater than the configured total-payment cap.
* Response: `claimStatus` = `ClaimStatus`; `caseStatus` = `ClaimCaseStatus`.
*/
async validateClaimFactorsV2(
@@ -2433,7 +2440,12 @@ export class ExpertClaimService {
};
}
- const priceCap = getClaimV2TotalPaymentCapToman();
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null &&
+ claimPriceCapAppliesToBlame(await this.loadBlameForClaim(claim))
+ ? configuredPriceCap
+ : null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of updatedReply.parts || []) {
@@ -2448,7 +2460,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
- message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
+ message: `You have reached the maximum acceptable total price (Rial). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${priceCap.toLocaleString()}).`,
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap,
@@ -3259,7 +3271,7 @@ export class ExpertClaimService {
* - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status
- * - Total payment across all parts must not exceed 53,000,000 (same cap as factor validation totals)
+ * - V1 only: total payment across all parts must not exceed the configured cap
* - Each part must include `daghi` (option + conditional price) like V1
*
* On success:
@@ -3364,8 +3376,12 @@ export class ExpertClaimService {
throw new BadRequestException(pricingValidationError);
}
- // Price cap validation, when enabled.
- const priceCap = getClaimV2TotalPaymentCapToman();
+ // The configured total cap is enforced only for V1 user-created files.
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null && claimPriceCapAppliesToBlame(blame)
+ ? configuredPriceCap
+ : null;
if (priceCap !== null) {
let totalPrice = 0;
for (const part of reply.parts || []) {
@@ -3376,7 +3392,7 @@ export class ExpertClaimService {
}
if (totalPrice > priceCap) {
throw new BadRequestException({
- message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) تومان بیشتر است.`,
+ message: `مجموع مبلغ قطعات (${totalPrice.toLocaleString("fa-IR")}) از سقف مجاز (${priceCap.toLocaleString("fa-IR")}) ریال بیشتر است.`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice,
@@ -5125,7 +5141,11 @@ export class ExpertClaimService {
claim.blameRequestId
? this.blameRequestDbService.find(
{ _id: new Types.ObjectId(claim.blameRequestId.toString()) },
- { lean: true, select: "type parties expert.decision" },
+ {
+ lean: true,
+ select:
+ "type parties expert.decision creationMethod expertInitiated registrarInitiated callCenterInitiated initiatedByFieldExpertId initiatedByRegistrarId initiatedByCallCenterId",
+ },
)
: Promise.resolve([]),
]);
@@ -5160,6 +5180,11 @@ export class ExpertClaimService {
const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean)
: {};
+ const configuredPriceCap = getClaimV2TotalPaymentCapToman();
+ const priceCap =
+ configuredPriceCap !== null && claimPriceCapAppliesToBlame(linkedBlame)
+ ? configuredPriceCap
+ : null;
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
if (videoCaptureRow) {
@@ -5269,6 +5294,7 @@ export class ExpertClaimService {
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext,
+ priceCap,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,
diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts
index fcfdb99..6e41a1c 100644
--- a/src/expert-claim/expert-claim.v2.controller.ts
+++ b/src/expert-claim/expert-claim.v2.controller.ts
@@ -268,7 +268,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
- "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 100,000–10,000,000,000 **Toman**. **Cap:** sum of line `totalPayment` values ≤ 53,000,000 **Toman** (same limit as factor-validation totals across priced + factor lines). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
+ "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each `parts[]` line needs `partId` (from GET claim detail `damagedParts[].partId`), plus pricing, `daghi`, and optional `factorNeeded`. Every supplied monetary field (`price`, `salary`, `totalPayment`, and `daghi.price`) must be 1,000,000–100,000,000,000 **Rial**. **V1-only cap:** for user-created V1 files, sum of line `totalPayment` values ≤ 530,000,000 **Rial** (same limit as factor-validation totals across priced + factor lines). V2–V6 files are uncapped. Claim detail exposes the effective `priceCap` (`null` when uncapped). Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" +
"**Frontend routing by `ClaimCaseStatus` (`status`):**\n" +
"- **All parts `factorNeeded`:** `OWNER_REPAIR_FACTOR_UPLOAD_PENDING`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors; then `status` becomes **`EXPERT_VALIDATING_REPAIR_FACTORS`**, `claimStatus=UNDER_REVIEW`, `currentStep=EXPERT_COST_EVALUATION` for expert **validate-factors**.\n" +
"- **Mixed (some priced, some factorNeeded):** `INSURER_REVIEW_MIXED_FACTORS_PENDING`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance); `currentStep` then moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` (same case `status` until factors are done).\n" +
@@ -353,7 +353,7 @@ export class ExpertClaimV2Controller {
"**Response:** `claimStatus` = `ClaimStatus` (e.g. APPROVED). `caseStatus` = `ClaimCaseStatus` (e.g. COMPLETED vs insurer-review) — they are not interchangeable.\n\n" +
"**Preconditions:** `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`), `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" +
"**Decisions:** each factor line gets `APPROVED` or `REJECTED`. **Every** decided line must include expert-entered `totalPayment` **or** both `price` and `salary` (factor photos are not read for amounts).\n\n" +
- "**Cap (when every factor line is decided):** sum of **all** reply lines (priced parts + factor lines) must be ≤ **53,000,000 Toman**; otherwise `PRICE_CAP_ERROR` with message that the maximum acceptable total was exceeded.\n\n" +
+ "**V1-only cap (when every factor line is decided):** for user-created V1 files, sum of **all** reply lines (priced parts + factor lines) must be ≤ **530,000,000 Rial**; otherwise `PRICE_CAP_ERROR` is returned. V2–V6 files are uncapped.\n\n" +
"**Outcomes:**\n" +
"- **All approved:** `caseStatus=COMPLETED`, `claimStatus=APPROVED`, workflow `CLAIM_COMPLETED` — no owner signature. V5 instead waits for FileMaker approval.\n" +
"- **Any rejected (repriced):** same completion behavior for now (V5 waits for FileMaker approval).\n" +
diff --git a/src/helpers/claim-price-cap.spec.ts b/src/helpers/claim-price-cap.spec.ts
new file mode 100644
index 0000000..20f8eb4
--- /dev/null
+++ b/src/helpers/claim-price-cap.spec.ts
@@ -0,0 +1,25 @@
+import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
+import { claimPriceCapAppliesToBlame } from "./claim-price-cap";
+
+describe("claimPriceCapAppliesToBlame", () => {
+ it.each([
+ [{ creationMethod: CreationMethod.NORMAL }],
+ [{}],
+ ])("applies to V1 user-created files (%p)", (blame) => {
+ expect(claimPriceCapAppliesToBlame(blame)).toBe(true);
+ });
+
+ it.each([
+ [{ creationMethod: CreationMethod.LINK, expertInitiated: true }],
+ [{ creationMethod: CreationMethod.IN_PERSON, expertInitiated: true }],
+ [{ creationMethod: CreationMethod.IN_PERSON, registrarInitiated: true }],
+ [{ creationMethod: CreationMethod.LINK, callCenterInitiated: true }],
+ [{ creationMethod: CreationMethod.NORMAL, initiatedByCallCenterId: "agent" }],
+ ])("does not apply to non-V1 files (%p)", (blame) => {
+ expect(claimPriceCapAppliesToBlame(blame)).toBe(false);
+ });
+
+ it("does not apply when the linked blame origin is unavailable", () => {
+ expect(claimPriceCapAppliesToBlame(null)).toBe(false);
+ });
+});
diff --git a/src/helpers/claim-price-cap.ts b/src/helpers/claim-price-cap.ts
new file mode 100644
index 0000000..ecf41a2
--- /dev/null
+++ b/src/helpers/claim-price-cap.ts
@@ -0,0 +1,39 @@
+import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
+
+export type ClaimPriceCapBlameOrigin = {
+ creationMethod?: CreationMethod | string | null;
+ expertInitiated?: boolean | null;
+ registrarInitiated?: boolean | null;
+ callCenterInitiated?: boolean | null;
+ initiatedByFieldExpertId?: unknown;
+ initiatedByRegistrarId?: unknown;
+ initiatedByCallCenterId?: unknown;
+};
+
+/**
+ * The total-payment cap is a V1 business rule. V1 files are created directly
+ * by a user (`NORMAL`); expert/registrar LINK or IN_PERSON files and V6
+ * call-center files must not inherit it. Missing creationMethod is treated as
+ * NORMAL for older user-created records, while initiator markers take
+ * precedence so legacy non-V1 records cannot be misclassified.
+ */
+export function claimPriceCapAppliesToBlame(
+ blame?: ClaimPriceCapBlameOrigin | null,
+): boolean {
+ if (!blame) return false;
+
+ const hasNonV1Initiator =
+ blame.expertInitiated === true ||
+ blame.registrarInitiated === true ||
+ blame.callCenterInitiated === true ||
+ blame.initiatedByFieldExpertId != null ||
+ blame.initiatedByRegistrarId != null ||
+ blame.initiatedByCallCenterId != null;
+
+ if (hasNonV1Initiator) return false;
+
+ return (
+ blame.creationMethod == null ||
+ blame.creationMethod === CreationMethod.NORMAL
+ );
+}
diff --git a/src/request-management/inquiry-participant-resolver.spec.ts b/src/request-management/inquiry-participant-resolver.spec.ts
index 7cf6036..b807e18 100644
--- a/src/request-management/inquiry-participant-resolver.spec.ts
+++ b/src/request-management/inquiry-participant-resolver.spec.ts
@@ -495,6 +495,26 @@ describe("inquiry participant resolver", () => {
});
});
+ it("preserves a mapped provider message when the current result is unusable", async () => {
+ const currentPlate = {
+ leftDigits: "44",
+ centerAlphabet: "ب",
+ centerDigits: "111",
+ ir: "22",
+ };
+
+ await expect(
+ runCurrentPlateInquiry({
+ currentPlate,
+ query: async () => ({
+ mapped: { Error: { Message: "رکوردی یافت نشد" } },
+ }),
+ isUsable: () => false,
+ errorMessage: (value) => value.mapped.Error.Message,
+ }),
+ ).rejects.toThrow("رکوردی یافت نشد");
+ });
+
it("retains one failed current-plate attempt after a provider outage", async () => {
const currentPlate = {
leftDigits: "44",
diff --git a/src/request-management/inquiry-participant-resolver.ts b/src/request-management/inquiry-participant-resolver.ts
index b6adec4..de9e8e0 100644
--- a/src/request-management/inquiry-participant-resolver.ts
+++ b/src/request-management/inquiry-participant-resolver.ts
@@ -446,6 +446,7 @@ export async function runCurrentPlateInquiry(options: {
vin?: string;
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise;
isUsable: (value: T) => boolean;
+ errorMessage?: (value: T) => string | undefined;
}): Promise<{
value: T;
plateKind: "CURRENT";
@@ -479,7 +480,8 @@ export async function runCurrentPlateInquiry(options: {
usable: false,
});
const error = new BadRequestException(
- "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
+ options.errorMessage?.(value) ||
+ "بیمهنامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;
diff --git a/src/request-management/request-management.policyholder-inquiry.spec.ts b/src/request-management/request-management.policyholder-inquiry.spec.ts
index f37149e..3682edc 100644
--- a/src/request-management/request-management.policyholder-inquiry.spec.ts
+++ b/src/request-management/request-management.policyholder-inquiry.spec.ts
@@ -78,6 +78,34 @@ describe("RequestManagementService policyholder inquiry routing", () => {
);
});
+ it("propagates the mapped ESG Persian error for third-party insurance", async () => {
+ const service = Object.create(RequestManagementService.prototype) as any;
+ service.sandHubService = {
+ getTejaratBlockInquiry: jest.fn().mockResolvedValue({
+ raw: {
+ success: false,
+ error: {
+ code: "RECORD_NOT_FOUND",
+ message: "Provider request failed",
+ messageFa: "رکوردی یافت نشد",
+ },
+ },
+ mapped: { Error: { Message: "رکوردی یافت نشد" } },
+ }),
+ };
+ const submission = service.normalizeInquiryInput(
+ BlameRequestType.THIRD_PARTY,
+ participantInput(BlameRequestType.THIRD_PARTY),
+ );
+
+ await expect(
+ service.getThirdPartyPlateInquiry(submission),
+ ).rejects.toThrow("رکوردی یافت نشد");
+ expect(service.sandHubService.getTejaratBlockInquiry).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+
it("does not fall back from the current plate for car-body insurance", async () => {
const service = Object.create(RequestManagementService.prototype) as any;
service.sandHubService = {
diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts
index 0dfb1ab..e856449 100644
--- a/src/request-management/request-management.service.ts
+++ b/src/request-management/request-management.service.ts
@@ -147,6 +147,13 @@ import {
getInquiryErrorMessage,
} from "src/common/utils/inquiry-error";
+function mappedInquiryErrorMessage(value: any): string | undefined {
+ const message = value?.mapped?.Error?.Message;
+ return typeof message === "string" && message.trim()
+ ? message.trim()
+ : undefined;
+}
+
/**
* Formats a compact Jalali date (number or string like 13780624) as YYYY/MM/DD.
* Returns the original value as a string if it cannot be parsed.
@@ -401,6 +408,7 @@ export class RequestManagementService {
!value?.mapped?.Error &&
!!value?.mapped?.CompanyName &&
isMappedPolicyCurrent(value.mapped),
+ errorMessage: mappedInquiryErrorMessage,
});
return {
...result.value,
@@ -435,6 +443,7 @@ export class RequestManagementService {
value.mapped.CompanyName ||
value.mapped.companyId
),
+ errorMessage: mappedInquiryErrorMessage,
});
return {
...result.value,
diff --git a/src/sand-hub/sand-hub.service.spec.ts b/src/sand-hub/sand-hub.service.spec.ts
index ef7b743..1ad9b20 100644
--- a/src/sand-hub/sand-hub.service.spec.ts
+++ b/src/sand-hub/sand-hub.service.spec.ts
@@ -155,28 +155,38 @@ describe("SandHubService inquiry mocks", () => {
expect(result.mapped.PrntPlcyCmpDocNo).toBe("REAL-ESG-POLICY");
});
- it("preserves ESG not-found semantics as a Persian plate-specific error", async () => {
+ it("preserves ESG messageFa for a plate inquiry", async () => {
process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
- message: "موردی یافت نشد",
+ error: {
+ code: "RECORD_NOT_FOUND",
+ message: "Provider request failed",
+ messageFa: "رکوردی یافت نشد",
+ providerMessage: "err.record.not.found",
+ providerCode: "RECORD_NOT_FOUND",
+ },
});
const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
- expect(result.mapped.Error.Message).toBe(
- "بیمهنامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
- );
+ expect(result.mapped.Error.Message).toBe("رکوردی یافت نشد");
});
- it("uses a VIN-specific message for the same ESG not-found response", async () => {
+ it("preserves ESG messageFa for a VIN inquiry", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
- message: "موردی یافت نشد",
+ error: {
+ code: "INQUIRY_NO_MATCH",
+ message: "Inquiry returned no matching result",
+ messageFa: "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
+ providerMessage: "Inquiry returned no matching result",
+ providerCode: "INQUIRY_NO_MATCH",
+ },
});
const result = await service.getPolicyByChassisInquiry({
@@ -185,7 +195,7 @@ describe("SandHubService inquiry mocks", () => {
});
expect(result.mapped.Error.Message).toBe(
- "بیمهنامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
+ "نتیجهای مطابق با اطلاعات وارد شده یافت نشد",
);
});