fix: harden claim review and inquiry workflows

Preserve damage history and current vehicle price, restore depreciation mapping, normalize inquiry/report output, and support resumable expert review with paginated case retrieval.
This commit is contained in:
2026-09-18 16:04:33 +03:30
parent a84d83a135
commit 7d1a50db7b
38 changed files with 2345 additions and 370 deletions

View File

@@ -1,6 +1,7 @@
import { SandHubService } from "./sand-hub.service";
import {
ForbiddenException,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
@@ -58,7 +59,9 @@ describe("SandHubService inquiry mocks", () => {
});
it("keeps disabled-live third-party mock policy usable", async () => {
const result = await service.getTejaratBlockInquiry(userDetail);
const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
expect(isMappedPolicyCurrent(result.mapped)).toBe(true);
});
@@ -121,6 +124,17 @@ describe("SandHubService inquiry mocks", () => {
expect(httpService.post).not.toHaveBeenCalled();
});
it("returns a contextual Persian error when no car-body policy matches", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
lookupsService.findLastProcessedCarPolicy.mockRejectedValue(
new NotFoundException("No active Fanavaran car-body policy was found"),
);
await expect(service.getCarBodyInquiry(userDetail)).rejects.toThrow(
"بیمه‌نامه بدنه فعالی مطابق پلاک و کد ملی واردشده یافت نشد.",
);
});
it("does not let an offline third-party seed override a live ESG inquiry", async () => {
process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
@@ -129,12 +143,10 @@ describe("SandHubService inquiry mocks", () => {
raw: { mocked: true },
mapped: { PrntPlcyCmpDocNo: "MOCK-POLICY" },
});
const esg = jest
.spyOn(service as any, "makeEsgRequest")
.mockResolvedValue({
success: true,
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
});
const esg = jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: true,
data: { PrntCmpDocNo: "REAL-ESG-POLICY" },
});
const result = await service.getTejaratBlockInquiry(userDetail);
@@ -143,6 +155,40 @@ 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 () => {
process.env.CLIENT_ID = "8";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
message: "موردی یافت نشد",
});
const result = await service.getTejaratBlockInquiry(userDetail, {
enforceDeploymentClientMatch: true,
});
expect(result.mapped.Error.Message).toBe(
"بیمه‌نامه شخص ثالثی مطابق پلاک و کد ملی واردشده یافت نشد.",
);
});
it("uses a VIN-specific message for the same ESG not-found response", async () => {
externalInquirySettings.isInquiryLive.mockResolvedValue(true);
jest.spyOn(service as any, "makeEsgRequest").mockResolvedValue({
success: false,
message: "موردی یافت نشد",
});
const result = await service.getPolicyByChassisInquiry({
nationalCode: "0012345678",
chassis: "NAAR03HFFRDE07024",
});
expect(result.mapped.Error.Message).toBe(
"بیمه‌نامه شخص ثالثی مطابق شماره شاسی (VIN) و کد ملی واردشده یافت نشد.",
);
});
it("rejects a guilty third-party policy issued by another insurer", async () => {
process.env.CLIENT_ID = "15";
externalInquirySettings.isInquiryLive.mockResolvedValue(true);

View File

@@ -24,6 +24,13 @@ import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { firstValueFrom } from "rxjs";
import { mapEsgCarBodyPolicyToInquiry } from "./esg-car-body-inquiry.mapper";
import type { Plates } from "src/Types&Enums/plate.interface";
import {
getInquiryErrorMessage,
inquiryErrorStatus,
isInquiryFailurePayload,
isInquiryTimeout,
type InquiryErrorContext,
} from "src/common/utils/inquiry-error";
type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
plate: Plates | string;
@@ -31,9 +38,6 @@ type CarBodyInquiryDetail = Omit<SandHubDetailDto, "plate"> & {
@Injectable()
export class SandHubService {
private static readonly ESG_INQUIRY_UNAVAILABLE_MESSAGE =
"استعلام در دسترس نیست";
private readonly logger = new Logger(SandHubService.name);
private loginToken: string | null = null;
private tokenExpiry: Date | null = null;
@@ -90,6 +94,30 @@ export class SandHubService {
return resolveFanavaranClientKey() === "parsian";
}
private inquiryContext(type: ExternalInquiryType): InquiryErrorContext {
if (type === "vinChassis") return "thirdPartyVin";
return type;
}
private throwInquiryError(
error: unknown,
context: InquiryErrorContext,
): never {
const message = getInquiryErrorMessage(error, context);
const status = inquiryErrorStatus(error);
if (error instanceof ForbiddenException || status === 403) {
throw new ForbiddenException(message);
}
if (isInquiryTimeout(error) || status === 504) {
throw new GatewayTimeoutException(message);
}
if ([400, 404, 409, 422].includes(status ?? 0)) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
}
/**
* A case may proceed only when the policy belongs to the insurer served by
* this deployment. Callers opt in for the guilty/first-party policy only.
@@ -101,7 +129,7 @@ export class SandHubService {
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
if (!expectedClientCode) {
throw new ServiceUnavailableException(
"CLIENT_ID must be configured before insurance eligibility can be checked.",
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمه‌نامه کامل نیست.",
);
}
@@ -111,7 +139,7 @@ export class SandHubService {
if (actualClientCode === expectedClientCode) return;
throw new ForbiddenException(
`${insuranceLine} policy insurer does not match this deployment.`,
"بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.",
);
}
@@ -134,13 +162,13 @@ export class SandHubService {
const expectedClientCode = String(process.env.CLIENT_ID ?? "").trim();
if (!expectedClientCode) {
throw new ServiceUnavailableException(
"CLIENT_ID must be configured before insurance eligibility can be checked.",
"تنظیمات شرکت بیمه برای بررسی اعتبار بیمه‌نامه کامل نیست.",
);
}
if (expectedClientCode === "8") return;
throw new ForbiddenException(
"CAR_BODY policy insurer does not match this deployment.",
"بیمه‌نامه یافت‌شده متعلق به شرکت بیمه این سامانه نیست.",
);
}
@@ -426,7 +454,7 @@ export class SandHubService {
this.logger.error("Failed to login to SandHub:", er.message);
this.loginToken = null;
this.tokenExpiry = null;
throw new UnauthorizedException("SandHub authentication failed");
throw new UnauthorizedException("احراز هویت سرویس استعلام انجام نشد.");
}
}
@@ -446,7 +474,7 @@ export class SandHubService {
if (!email || !password) {
throw new UnauthorizedException(
"Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)",
"اطلاعات اتصال به سرویس استعلام تجارت نو تنظیم نشده است.",
);
}
@@ -486,12 +514,18 @@ export class SandHubService {
);
this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null;
throw new UnauthorizedException("Tejarat inquiry authentication failed");
throw new UnauthorizedException(
"احراز هویت سرویس استعلام تجارت نو انجام نشد.",
);
}
}
private async getEsgAccessToken(): Promise<string> {
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
if (
this.esgAccessToken &&
this.esgTokenExpiry &&
this.esgTokenExpiry > new Date()
) {
return this.esgAccessToken;
}
@@ -501,7 +535,7 @@ export class SandHubService {
if (!baseUrl || !username || !password) {
throw new UnauthorizedException(
"ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)",
"اطلاعات اتصال به سرویس استعلام ESG تنظیم نشده است.",
);
}
@@ -539,7 +573,9 @@ export class SandHubService {
this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
this.esgAccessToken = null;
this.esgTokenExpiry = null;
throw new UnauthorizedException("ESG inquiry authentication failed");
throw new UnauthorizedException(
"احراز هویت سرویس استعلام ESG انجام نشد.",
);
}
}
@@ -603,24 +639,30 @@ export class SandHubService {
this.esgTokenExpiry = null;
}
if ([400, 404, 409, 422].includes(status)) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
if (attempt === maxRetries - 1) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) throw err;
}
}
}
private mapEsgPolicyByPlateToOldFormat(raw: any): any {
private mapEsgPolicyByPlateToOldFormat(
raw: any,
context: "thirdPartyPlate" | "thirdPartyVin" = "thirdPartyPlate",
): any {
if (!raw) return raw;
if (raw?.success === false) {
this.logger.warn(
"ESG policyByPlate inquiry returned success=false",
raw,
);
if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG policy inquiry returned a failure payload", raw);
return {
Error: {
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
Message: getInquiryErrorMessage(raw, context),
},
};
}
@@ -670,7 +712,8 @@ export class SandHubService {
): string | null {
if (input === null || input === undefined) return null;
const raw = typeof input === "number" ? String(input) : String(input).trim();
const raw =
typeof input === "number" ? String(input) : String(input).trim();
if (!raw) return null;
let year = 0;
@@ -700,7 +743,9 @@ export class SandHubService {
return `${year}-${mm}-${dd}`;
}
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> {
private getDefaultMockPersonInquiry(
nationalCode: string,
): Record<string, unknown> {
return {
firstName: "نام",
lastName: "خانوادگی",
@@ -711,10 +756,10 @@ export class SandHubService {
}
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) {
if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG person inquiry returned success=false", raw);
throw new BadRequestException(
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
getInquiryErrorMessage(raw, "personalIdentity"),
);
}
@@ -736,11 +781,9 @@ export class SandHubService {
}
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) {
if (isInquiryFailurePayload(raw)) {
this.logger.warn("ESG sheba inquiry returned success=false", raw);
throw new BadRequestException(
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
);
throw new BadRequestException(getInquiryErrorMessage(raw, "sheba"));
}
const data = raw?.data ?? {};
@@ -800,11 +843,15 @@ export class SandHubService {
this.tejaratTokenExpiry = null;
}
if ([400, 404, 409, 422].includes(status)) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
if (attempt === maxRetries - 1) {
this.throwInquiryError(err, this.inquiryContext(inquiryType));
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) {
throw err;
}
}
}
}
@@ -883,8 +930,13 @@ export class SandHubService {
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = this.mapEsgPolicyByPlateToOldFormat(
raw,
"thirdPartyPlate",
);
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
@@ -912,8 +964,16 @@ export class SandHubService {
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const mapped = this.mapNewApiResponseToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = isInquiryFailurePayload(raw)
? {
Error: {
Message: getInquiryErrorMessage(raw, "thirdPartyPlate"),
},
}
: this.mapNewApiResponseToOldFormat(raw);
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
@@ -962,13 +1022,15 @@ export class SandHubService {
}
if (isVinInquiry) {
const raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
{
let raw: any;
try {
raw = await this.lookupsService.findLastProcessedCarPolicy("car-body", {
nationalCode: String(userDetail.nationalCodeOfInsurer),
vin: plateOrVin,
},
);
});
} catch (error) {
this.throwInquiryError(error, "carBodyVin");
}
if (useParsianCarBodyLookup) {
this.assertParsianCarBodyLookupMatchesDeployment();
}
@@ -1000,10 +1062,15 @@ export class SandHubService {
plaqueRight: String(plateOrVin.centerDigits),
plaqueSerial: String(plateOrVin.ir),
};
const raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
query,
);
let raw: any;
try {
raw = await this.lookupsService.findLastProcessedCarPolicy(
"car-body",
query,
);
} catch (error) {
this.throwInquiryError(error, "carBodyPlate");
}
this.assertParsianCarBodyLookupMatchesDeployment();
return {
@@ -1046,6 +1113,11 @@ export class SandHubService {
options,
);
if (isInquiryFailurePayload(raw)) {
throw new BadRequestException(
getInquiryErrorMessage(raw, "carBodyPlate"),
);
}
const mapped = this.mapCarBodyInquiryResponse(raw);
return { raw, mapped };
}
@@ -1114,7 +1186,6 @@ export class SandHubService {
};
}
/**
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
*
@@ -1130,10 +1201,10 @@ export class SandHubService {
options?: SandHubInquiryOptions,
): Promise<{ raw: any; mapped: any }> {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestUrl = `${baseUrl}/inquiry/carByChassis`;
const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
const requestPayload = {
nationalCode: String(identity.nationalCode),
chassisNo: String(identity.chassis),
chassis: String(identity.chassis),
};
const live = await this.isInquiryLive("vinChassis", options);
@@ -1144,8 +1215,10 @@ export class SandHubService {
this.logger.debug(
`[MOCK] getPolicyByChassisInquiry nationalCode=${identity.nationalCode} chassis=${identity.chassis}`,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
@@ -1155,12 +1228,13 @@ export class SandHubService {
"vinChassis",
options,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw, "thirdPartyVin");
if (!mapped?.Error) {
this.enforceDeploymentClientMatch(mapped, "THIRD_PARTY", options);
}
return { raw, mapped };
}
private async makeSandHubRequest(
url: string,
payload: any,
@@ -1205,7 +1279,7 @@ export class SandHubService {
}
}
throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries",
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
);
}
@@ -1229,11 +1303,16 @@ export class SandHubService {
// Pattern: {centerDigits}{centerLetter(s)}{leftDigits}<space>{ir}
const m = plk.trim().match(/^(\d+)([^\d\s]+)(\d+)\s+(\d+)$/);
if (!m) return null;
const Plk3 = parseInt(m[1], 10); // center digits
const Plk3 = parseInt(m[1], 10); // center digits
const Plk2 = this.plateNormalizer.normalizePlateText(m[2]);
const Plk1 = parseInt(m[3], 10); // left digits
const Plk1 = parseInt(m[3], 10); // left digits
const PlkSrl = parseInt(m[4], 10); // IR region code
if (!Number.isFinite(Plk3) || !Number.isFinite(Plk1) || !Number.isFinite(PlkSrl) || !Plk2) {
if (
!Number.isFinite(Plk3) ||
!Number.isFinite(Plk1) ||
!Number.isFinite(PlkSrl) ||
!Plk2
) {
return null;
}
return { Plk1, Plk2, Plk3, PlkSrl };
@@ -1245,7 +1324,12 @@ export class SandHubService {
// If the response carries a `plk` plate string (VIN inquiry) but lacks the
// individual Plk1/Plk2/Plk3/PlkSrl fields, parse and inject them so that
// all downstream plate-handling code works identically to the plate flow.
let plkParts: { Plk1: number; Plk2: string; Plk3: number; PlkSrl: number } | null = null;
let plkParts: {
Plk1: number;
Plk2: string;
Plk3: number;
PlkSrl: number;
} | null = null;
if (
newResponse.plk &&
newResponse.Plk1 == null &&
@@ -1258,13 +1342,15 @@ export class SandHubService {
// Map the new field names to the old field names
return {
...newResponse,
...(plkParts ? {
Plk1: plkParts.Plk1,
Plk2: plkParts.Plk2,
Plk3: plkParts.Plk3,
PlkSrl: plkParts.PlkSrl,
plateLetterid: plkParts.Plk2,
} : {}),
...(plkParts
? {
Plk1: plkParts.Plk1,
Plk2: plkParts.Plk2,
Plk3: plkParts.Plk3,
PlkSrl: plkParts.PlkSrl,
plateLetterid: plkParts.Plk2,
}
: {}),
// Company information
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
@@ -1401,7 +1487,7 @@ export class SandHubService {
) {
throw err;
}
throw new Error(err);
this.throwInquiryError(err, "thirdPartyPlate");
}
}
@@ -1420,7 +1506,7 @@ export class SandHubService {
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
if (!jalaliBirthDate) {
throw new BadRequestException(
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13781124 or "1378-11-24").`,
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
);
}
@@ -1454,7 +1540,7 @@ export class SandHubService {
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
if (!gregorianBirthdate) {
throw new BadRequestException(
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
"تاریخ تولد واردشده برای استعلام هویت معتبر نیست.",
);
}
@@ -1472,7 +1558,7 @@ export class SandHubService {
if (response?.message?.includes("err.record.not.found")) {
throw new NotFoundException(
"Personal inquiry failed: Record not found for the given national code and birth date.",
getInquiryErrorMessage(response, "personalIdentity"),
);
}
return response.data;
@@ -1483,7 +1569,7 @@ export class SandHubService {
) {
throw err;
}
throw new Error(`Error in finding personal inquiry: ${err}`);
this.throwInquiryError(err, "personalIdentity");
}
}
@@ -1512,22 +1598,13 @@ export class SandHubService {
if (response?.data?.IsSucceed === false) {
throw new NotFoundException(
"Driving license check failed: The license is not valid or could not be found.",
"گواهینامه‌ای مطابق کد ملی و شماره گواهینامه واردشده یافت نشد.",
);
}
return response.data;
} catch (error) {
if (
error instanceof BadGatewayException &&
error.message.includes("multiple retries")
) {
throw new BadGatewayException(
`Driving license check failed after multiple retries. The service may be down.`,
);
}
// For all other errors (like 400, 404, etc.), re-throw them as-is.
throw new Error(`Error in finding driving license: ${error}`);
this.throwInquiryError(error, "drivingLicense");
}
}
@@ -1563,13 +1640,13 @@ export class SandHubService {
response,
);
throw new BadRequestException(
"Ownership validation failed: The provided national ID is not the owner of this vehicle.",
"پلاک واردشده متعلق به کد ملی واردشده نیست.",
);
}
return response;
} catch (err) {
throw new Error(`Error in finding car ownership: ${err}`);
this.throwInquiryError(err, "carOwnership");
}
}
@@ -1619,7 +1696,7 @@ export class SandHubService {
response,
);
throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
"شماره شبا متعلق به کد ملی واردشده نیست.",
);
}
@@ -1647,7 +1724,7 @@ export class SandHubService {
response,
);
throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
"شماره شبا متعلق به کد ملی واردشده نیست.",
);
}
@@ -1656,7 +1733,7 @@ export class SandHubService {
if (err instanceof BadRequestException) {
throw err;
}
throw new Error(`Error in matching sheba validation: ${err}`);
this.throwInquiryError(err, "sheba");
}
}
@@ -1672,9 +1749,7 @@ export class SandHubService {
);
if (err.response.status === 400) {
throw new BadGatewayException(
`SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`,
);
throw new BadGatewayException(getInquiryErrorMessage(err, "generic"));
}
} else {
this.logger.error(
@@ -1685,23 +1760,24 @@ export class SandHubService {
if (err.message === "EMPTY_RESPONSE") {
throw new BadGatewayException(
"SandHub is offline or returned an empty response",
"سرویس استعلام پاسخی برنگرداند. لطفاً دوباره تلاش کنید.",
);
}
if (err.code === "ECONNABORTED") {
throw new GatewayTimeoutException("SandHub request timed out");
throw new GatewayTimeoutException(
"زمان پاسخ‌گویی سرویس استعلام به پایان رسید. لطفاً دوباره تلاش کنید.",
);
}
if (err.code === "ECONNRESET" || err.message.includes("socket hang up")) {
throw new ServiceUnavailableException(
"SandHub connection was reset or closed unexpectedly",
"ارتباط با سرویس استعلام قطع شد. لطفاً دوباره تلاش کنید.",
);
}
// This final check is for when all retries have failed for a retryable error.
if (attempt >= maxRetries) {
if (attempt >= maxRetries - 1) {
throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries",
err.message,
"سرویس استعلام پس از چند تلاش پاسخ نداد. لطفاً کمی بعد دوباره تلاش کنید.",
);
}
}