From 45e0ad883a39d11370947963eff66414c1e4f60a Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 19 Sep 2026 11:26:40 +0330 Subject: [PATCH 1/2] fix: query policies only for current holder --- docs/external-integrations-reference.fa.html | 1 + docs/external-integrations-reference.html | 1 + .../inquiry-participants-frontend-guide.fa.md | 2 +- docs/inquiry-participants-proposal.fa.md | 6 +- docs/inquiry-participants-proposal.md | 6 +- .../inquiry-participant-resolver.spec.ts | 193 ++++------------ .../inquiry-participant-resolver.ts | 211 +++++------------- ...t-management.policyholder-inquiry.spec.ts} | 78 ++++--- .../request-management.service.ts | 30 +-- 9 files changed, 153 insertions(+), 375 deletions(-) rename src/request-management/{request-management.previous-policyholder.spec.ts => request-management.policyholder-inquiry.spec.ts} (55%) diff --git a/docs/external-integrations-reference.fa.html b/docs/external-integrations-reference.fa.html index e3e32af..1626b01 100644 --- a/docs/external-integrations-reference.fa.html +++ b/docs/external-integrations-reference.fa.html @@ -159,6 +159,7 @@
برای هر پرس‌وجوی مبتنی بر پلاک:
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 @@ - + - + - + 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 @@
متدمسیرتوضیح
GETv2/expert-claim/requestsفهرست خسارت‌ها در صف WAITING_FOR_DAMAGE_EXPERT + صف اعتبارسنجی فاکتور. پارامترها: search، sortBy، page، limit، unifiedStatus، fileType.
GETv2/expert-claim/request/:claimRequestIdجزئیات کامل خسارت: قطعات آسیب‌دیده، تصاویر گرفته‌شده، اسناد، priceDrop، داده طرف بلیم، آدرس‌های ویدیو.
GETv2/expert-claim/request/:claimRequestIdجزئیات کامل خسارت، به‌همراه priceCap مؤثر: ۵۳۰٬۰۰۰٬۰۰۰ ریال برای V1 و null برای V2 تا V6 یا وقتی سقف غیرفعال است.
POSTv2/expert-claim/assign/:claimRequestIdقفل خسارت برای این کارشناس. بازمی‌گرداند: assigned، already_assigned_to_you، یا ۴۰۹.
GETv2/expert-claim/request/:claimRequestId/price-dropمحتوای کاهش قیمت: برچسب‌های شدت، کاتالوگ ضریب، قطعات آسیب‌دیده + نگاشت، سال پیشنهادی خودرو از استعلام تقصیر.
PUTv2/expert-claim/request/:claimRequestId/price-dropمحاسبه و ذخیره کاهش قیمت: قیمت خودرو × ضریب سال × مجموع ضرایب ÷ ۴۰۰.
PUTv2/expert-claim/reply/submit/:claimRequestIdارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). سقف: کل ≤ ۵۳،۰۰۰،۰۰۰ تومان. بسته به پرچم‌های factorNeeded، خسارت را به owner-sign، mixed-factors-pending، یا صف اعتبارسنجی فاکتور منتقل می‌کند.
PUTv2/expert-claim/reply/submit/:claimRequestIdارسال پاسخ ارزیابی خسارت (لیست قطعات قیمت‌گذاری‌شده، داغی، branchId). فقط V1: کل ≤ ۵۳۰٬۰۰۰٬۰۰۰ ریال؛ V2 تا V6 بدون سقف مجموع هستند.
PUTv2/expert-claim/reply/resend/:claimRequestIdدرخواست از کاربر برای ارسال مجدد اسناد/عکس‌ها. یک ارسال مجدد در هر چرخه خسارت؛ در صورت تکمیل قبلی ۴۲۲ برمی‌گرداند.
PATCHv2/expert-claim/:claimRequestId/visitدرخواست از کاربر برای مراجعه حضوری. خسارت را آزاد می‌کند، وضعیت claimStatus را به NEEDS_REVISION تنظیم می‌کند.
PATCHv2/expert-claim/validate-factors/:claimRequestIdاعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. تأیید یا رد هر خط فاکتور با totalPayment. سقف برای تمام خطوط اعمال می‌شود (≤ ۵۳،۰۰۰،۰۰۰ تومان). پس از تصمیم‌گیری درباره تمام خطوط، به‌صورت خودکار تکمیل می‌شود.
PATCHv2/expert-claim/validate-factors/:claimRequestIdاعتبارسنجی فاکتورهای تعمیرگاه بارگذاری‌شده. سقف ۵۳۰٬۰۰۰٬۰۰۰ ریال تمام خطوط فقط برای V1 اعمال می‌شود؛ V2 تا V6 بدون سقف هستند.
PATCHv2/expert-claim/request/:claimRequestId/damaged-partsویرایش قطعات آسیب‌دیده انتخاب‌شده در حالی که خسارت توسط این کارشناس قفل است (EXPERT_REVIEWING).
GETv2/expert-claim/outer-parts-catalogکاتالوگ قطعات بیرونی خودرو فناوران (مشترک با جریان کاربر).
GETv2/expert-claim/inner-parts-catalogJSON ثابت کاتالوگ قطعات داخلی خودرو.
- + - + - + 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) و کد ملی واردشده یافت نشد.", + "نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد", ); });
MethodRouteWhat it does
GETv2/expert-claim/requestsList claims in WAITING_FOR_DAMAGE_EXPERT queue + factor-validation queue. Query: search, sortBy, page, limit, unifiedStatus, fileType.
GETv2/expert-claim/request/:claimRequestIdFull claim detail: damaged parts, captured images, documents, priceDrop, blameCase party data, video URLs. Completed claims include Fanavaran claimNo / claimId when available.
GETv2/expert-claim/request/:claimRequestIdFull 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.
POSTv2/expert-claim/assign/:claimRequestIdLock claim to this expert. Returns assigned, already_assigned_to_you, or 409.
GETv2/expert-claim/request/:claimRequestId/price-dropPrice-drop context: severity labels, coefficient catalog, damaged parts + mapping, suggested car year from blame inquiry.
PUTv2/expert-claim/request/:claimRequestId/price-dropCalculate and persist price-drop: carPrice × yearCoeff × sumOfCoeffs ÷ 400.
PUTv2/expert-claim/reply/submit/:claimRequestIdSubmit damage assessment reply (priced parts list and daghi). daghi.branchId is used only with the تحویل داغی option. Cap: total ≤ 53 000 000 Toman. A priced-only claim completes immediately; factor claims continue through factor collection/validation. No final owner signature or automatic Fanavaran submission.
PUTv2/expert-claim/reply/submit/:claimRequestIdSubmit 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.
PUTv2/expert-claim/reply/resend/:claimRequestIdRequest user to resend documents/photos. One resend per claim lifecycle; returns 422 if already fulfilled.
PATCHv2/expert-claim/:claimRequestId/visitAsk user to come in person. Unlocks claim, sets claimStatus to NEEDS_REVISION.
PATCHv2/expert-claim/validate-factors/:claimRequestIdValidate uploaded repair factor invoices. Approve or reject each factor line with totalPayment. Cap applies across all lines (≤ 53 000 000 Toman). Auto-completes when all lines are decided.
PATCHv2/expert-claim/validate-factors/:claimRequestIdValidate 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.
PATCHv2/expert-claim/request/:claimRequestId/damaged-partsEdit selected damaged parts while the claim is locked by this expert (EXPERT_REVIEWING).
GETv2/expert-claim/outer-parts-catalogFanavaran outer car-components catalog (shared with user flow).
GETv2/expert-claim/inner-parts-catalogStatic inner car-parts catalog JSON.