fix: align inquiry errors and expert review rules

This commit is contained in:
SepehrYahyaee
2026-09-19 12:11:43 +03:30
parent 45e0ad883a
commit 406b139b3d
21 changed files with 457 additions and 55 deletions

View File

@@ -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", () => {

View File

@@ -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];
}

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>) => ({
...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<string, unknown>;
}>) {
if (

View File

@@ -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<string, unknown>;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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" +

View File

@@ -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);
});
});

View File

@@ -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
);
}

View File

@@ -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",

View File

@@ -446,6 +446,7 @@ export async function runCurrentPlateInquiry<T>(options: {
vin?: string;
query: (plate: InquiryVehicleInputDto["currentPlate"]) => Promise<T>;
isUsable: (value: T) => boolean;
errorMessage?: (value: T) => string | undefined;
}): Promise<{
value: T;
plateKind: "CURRENT";
@@ -479,7 +480,8 @@ export async function runCurrentPlateInquiry<T>(options: {
usable: false,
});
const error = new BadRequestException(
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
options.errorMessage?.(value) ||
"بیمه‌نامه معتبر و فعالی مطابق مشخصات خودرو و کد ملی واردشده یافت نشد.",
) as BadRequestException & { attempts?: typeof attempts };
error.attempts = attempts;
throw error;

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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) و کد ملی واردشده یافت نشد.",
"نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد",
);
});