Files
yara724api/src/sand-hub/sand-hub.service.ts
2026-06-21 12:18:08 +03:30

1387 lines
44 KiB
TypeScript

import { HttpService } from "@nestjs/axios";
import {
BadGatewayException,
BadRequestException,
GatewayTimeoutException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from "@nestjs/common";
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
import { ExternalInquirySettingsService } from "src/client/external-inquiry-settings.service";
import type { ExternalInquiryType } from "src/common/types/external-inquiry.types";
import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types";
import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto";
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { firstValueFrom } from "rxjs";
@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;
// Tejarat inquiry auth (82.99.202.245:3027)
private tejaratAccessToken: string | null = null;
private tejaratTokenExpiry: Date | null = null;
// ESG inquiry auth (used for selected tenants, e.g. CLIENT_ID=8)
private esgAccessToken: string | null = null;
private esgTokenExpiry: Date | null = null;
constructor(
private readonly httpService: HttpService,
private readonly sandHubDbService: SandHubDbService,
private readonly externalInquirySettings: ExternalInquirySettingsService,
) {}
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
return options?.clientId;
}
private async isInquiryLive(
type: ExternalInquiryType,
options?: SandHubInquiryOptions,
): Promise<boolean> {
return this.externalInquirySettings.isInquiryLive(
type,
this.clientRefFrom(options),
);
}
private async mockCompanyContext(
options?: SandHubInquiryOptions,
): Promise<MockInquiryCompanyContext> {
return this.externalInquirySettings.getMockCompanyContext(
this.clientRefFrom(options),
);
}
private shouldUseEsgInquiryProvider(): boolean {
return String(process.env.CLIENT_ID ?? "") === "8";
}
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
private buildMockPlateInquiryRaw(
ctx: MockInquiryCompanyContext,
): Record<string, unknown> {
return {
PrntPlcyCmpDocNo: "1404/1143-70591/200/123",
MapTypNam: "فائو ( FAW )",
MtrNum: "TZ196XYAP223A210074",
ShsNum: "LFP8C7PC3R1K12157",
DisFnYrNum: null,
DisLfYrNum: null,
DisPrsnYrNum: null,
DisPrsnYrPrcnt: null,
DisFnYrPrcnt: "5",
DisLfYrPrcnt: "5",
vin: "LFP8C7PC3R1K12157",
MapVehicleSystemName: "ثبت نشده",
LfCvrCptl: 0,
FnCvrCptl: 0,
PrsnCvrCptl: 0,
VehicleSystemCode: 1,
EdrsJson: "",
PersonCvrCptl: 12000000000,
LifeCvrCptl: 16000000000,
FinancialCvrCptl: 4000000000,
CarGroupCode: 3,
CylCnt: 4,
LastCompanyDocumentNumber: "31/3100/03/18350",
UsageCode: "8",
MapUsageCode: 1,
MapUsageName: "شخصي",
Plk1: 16,
Plk2: 12,
Plk3: 498,
PlkSrl: 60,
PrintEndorsCompanyDocumentNumber: "",
EndorseDate: null,
InsuranceFullName: null,
SystemField: "سايپا",
TypeField: "111SE",
UsageField: "سواري",
MainColorField: "سفيد",
SecondColorField: "سفيد شيري",
ModelField: "2024",
CapacityField: "جمعا 4 نفر",
CylinderNumberField: "4",
EngineNumberField: "5215907",
ChassisNumberField: "LFP8C7PC3R1K12157",
VinNumberField: "LFP8C7PC3R1K12157",
InstallDateField: "1403/03/01",
AxelNumberField: "2",
WheelNumberField: "4",
CompanyName: ctx.companyName,
CompanyCode: ctx.companyId,
IssueDate: "1403/04/06",
SatrtDate: "1403/04/06",
EndDate: "1404/04/06",
Thrname: "",
EndorseText: null,
PolicyHealthLossCount: 0,
PolicyFinancialLossCount: 0,
PolicyPersonLossCount: 0,
Tonage: 0,
ThirdPolicyCode: 10502330579,
DiscountPersonPercent: null,
DiscountThirdPercent: null,
SystemCodeCii: 0,
SystemNameCii: "ثبت نشده",
TypeCodeCii: 12189,
TypeNameCii: "فائو ( FAW )",
UsageNameCii: "سواري",
UsageCodeCii: 1,
ModelCii: 2024,
damageTypes: [],
StatusTypeCode: 1,
};
}
/** Build {@link SandHubDetailDto} from Tejarat badane / block-inquiry request bodies. */
private tejaratPayloadToSandHubDetail(payload: {
nationalCode?: string | number;
part1?: string | number;
part2?: string | number;
part3?: string | number;
part4?: string | number;
leftTwoDigits?: string | number;
serialLetter?: string | number;
threeDigits?: string | number;
rightTwoDigits?: string | number;
}): SandHubDetailDto {
const nationalCode = String(payload.nationalCode ?? "");
return {
nationalCodeOfInsurer: nationalCode,
plate: {
leftDigits: Number(payload.part1 ?? payload.leftTwoDigits ?? 16),
centerAlphabet: String(
payload.part2 ?? payload.serialLetter ?? "12",
),
centerDigits: Number(payload.part3 ?? payload.threeDigits ?? 498),
ir: Number(payload.part4 ?? payload.rightTwoDigits ?? 60),
nationalCode,
},
};
}
private buildMockCarBodyInquiryRaw(
userDetail: SandHubDetailDto,
ctx: MockInquiryCompanyContext,
): Record<string, unknown> {
const companyId = Number(ctx.companyId);
const plate = userDetail?.plate;
const platePartOne = Number(plate?.leftDigits ?? 16);
const platePartThree = Number(plate?.centerDigits ?? 498);
const plateSerialNumber = Number(plate?.ir ?? 60);
const plateLetterid = Number(plate?.centerAlphabet ?? 12);
const nationalCode = String(userDetail?.nationalCodeOfInsurer ?? "");
return {
data: {
id: 70016075946,
printNumber: "1405/1143-70591/220/1/0",
companyId: Number.isNaN(companyId) ? 15 : companyId,
companyName: ctx.companyName,
beginDate: "1405/01/17",
endDate: "1406/01/17",
issueDate: "1405/01/16",
hasEndorsement: null,
motorNumber: "TZ196XYAP223A210074",
chassisNumber: "LFP8C7PC3R1K12157",
vin: "LFP8C7PC3R1K12157",
plateTypeId: 9,
plateTypeTitle: "پلاک قدیمی",
platePartOne,
plateSerialNumber,
plateLetterid,
plateLetterTitle: "م ",
vehicleSystemTitle: "فائو ( FAW )",
vehicleGroupId: 2,
vehicleGroupTitle: "سواری چهار سیلندر",
insurerNationalCode: nationalCode,
insurerName: "هانيه کارخانهء",
ownerNationalCode: nationalCode,
previousId: 70006331822,
noLossYearsCount: 4,
platePartThree,
lossDocuments: [],
},
isSuccess: true,
statusCode: 200,
message: "عملیات با موفقیت انجام شد",
};
}
private buildMockInquiryRaw(
inquiryType: ExternalInquiryType,
ctx: MockInquiryCompanyContext,
payload?: unknown,
userDetail?: SandHubDetailDto,
): Record<string, unknown> {
if (inquiryType === "carBodyPlate") {
const detail =
userDetail ??
this.tejaratPayloadToSandHubDetail(
(payload ?? {}) as Record<string, unknown>,
);
return this.buildMockCarBodyInquiryRaw(detail, ctx);
}
return this.buildMockPlateInquiryRaw(ctx);
}
/**
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
*/
private buildMockSandHubResponseForUrl(
url: string,
payload?: unknown,
plateMock?: Record<string, unknown>,
): any {
const u = (url || "").toLowerCase();
if (u.includes("personal-inquiry")) {
const p = payload as { nationalCode?: string } | undefined;
const nin = typeof p?.nationalCode === "string" ? p.nationalCode : "-";
return {
message: "success",
data: {
firstName: "نام",
lastName: "خانوادگی",
fatherName: "-",
birthCertificateNumber: "-",
nin,
},
};
}
if (u.includes("driver-license-check")) {
return {
data: {
IsSucceed: true,
Message: "mock",
},
};
}
if (u.includes("ownership")) {
return {
data: {
IsSuccess: true,
},
};
}
if (u.includes("sheba")) {
return {
ReturnValue: true,
HasError: false,
Message: "mock-sheba-ok",
};
}
if (u.includes("badane")) {
return (
plateMock ??
this.buildMockCarBodyInquiryRaw(
this.tejaratPayloadToSandHubDetail(
(payload ?? {}) as Record<string, unknown>,
),
{
companyId: process.env.CLIENT_ID ?? "15",
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
},
)
);
}
if (u.includes("block-inquiry") || u.includes("tejarat")) {
return this.mapNewApiResponseToOldFormat(
plateMock ??
this.buildMockPlateInquiryRaw({
companyId: process.env.CLIENT_ID ?? "15",
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
}),
);
}
this.logger.warn(
`[MOCK] Unrecognized SandHub URL; returning generic OK: ${url}`,
);
return { data: {}, message: "mock-ok" };
}
private async getAccessToken(): Promise<string> {
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
return this.loginToken;
}
this.logger.log(
"SandHub token is missing or expired. Fetching a new one...",
);
try {
const response = await firstValueFrom(
this.httpService.post(process.env.SANHUB_URL_LOGIN, {
email: process.env.SANHUB_USERNAME,
password: process.env.SANHUB_PASSWORD,
}),
);
const token = response.data.accessToken;
if (!token)
throw new Error("No access token returned from SandHub login");
this.loginToken = token;
this.tokenExpiry = new Date(Date.now() + 55 * 60 * 1000); // 55-minute expiry
this.logger.log("Successfully obtained a new SandHub token.");
return this.loginToken;
} catch (er) {
this.logger.error("Failed to login to SandHub:", er.message);
this.loginToken = null;
this.tokenExpiry = null;
throw new UnauthorizedException("SandHub authentication failed");
}
}
private async getTejaratAccessToken(): Promise<string> {
if (
this.tejaratAccessToken &&
this.tejaratTokenExpiry &&
this.tejaratTokenExpiry > new Date()
) {
return this.tejaratAccessToken;
}
const baseUrl =
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
const email = process.env.TEJARAT_INQUIRY_EMAIL;
const password = process.env.TEJARAT_INQUIRY_PASSWORD;
if (!email || !password) {
throw new UnauthorizedException(
"Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)",
);
}
this.logger.log(
"Tejarat inquiry token is missing or expired. Fetching a new one...",
);
try {
const response = await firstValueFrom(
this.httpService.post(
`${baseUrl}/user/login`,
{ email, password },
{
headers: {
Accept: "*/*",
"Content-Type": "application/json",
},
timeout: 30000,
},
),
);
const token = response.data?.accessToken;
if (!token) {
throw new Error("No access token returned from Tejarat login");
}
this.tejaratAccessToken = token;
// No explicit expiry in response; keep a conservative cache window.
this.tejaratTokenExpiry = new Date(Date.now() + 55 * 60 * 1000);
return this.tejaratAccessToken;
} catch (er) {
this.logger.error(
"Failed to login to Tejarat inquiry:",
er?.message || er,
);
this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null;
throw new UnauthorizedException("Tejarat inquiry authentication failed");
}
}
private async getEsgAccessToken(): Promise<string> {
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
return this.esgAccessToken;
}
const baseUrl = process.env.ESG_URL;
const username = process.env.ESG_USERNAME;
const password = process.env.ESG_PASSWORD;
if (!baseUrl || !username || !password) {
throw new UnauthorizedException(
"ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)",
);
}
this.logger.log("ESG token is missing or expired. Fetching a new one...");
try {
const response = await firstValueFrom(
this.httpService.post(
`${baseUrl}/auth/login`,
{ username, password },
{
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
timeout: 30000,
},
),
);
const token = response.data?.accessToken;
if (!token) {
throw new Error("No access token returned from ESG login");
}
const expiresInSeconds = Number(response.data?.expiresIn ?? 900);
const ttlMs = Number.isFinite(expiresInSeconds)
? Math.max(30, expiresInSeconds - 60) * 1000
: 14 * 60 * 1000;
this.esgAccessToken = token;
this.esgTokenExpiry = new Date(Date.now() + ttlMs);
return this.esgAccessToken;
} catch (er: any) {
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");
}
}
private async makeEsgRequest(
url: string,
payload: any,
inquiryType: ExternalInquiryType,
options?: SandHubInquiryOptions,
maxRetries = 2,
) {
if (!(await this.isInquiryLive(inquiryType, options))) {
this.logger.log(`[MOCK] ESG POST skipped (${inquiryType}): ${url}`);
const ctx = await this.mockCompanyContext(options);
if (inquiryType === "personalIdentity") {
const nin =
typeof payload?.nationalCode === "string"
? payload.nationalCode
: "-";
return this.getDefaultMockPersonInquiry(nin);
}
if (inquiryType === "sheba") {
return {
ReturnValue: true,
HasError: false,
Message: "mock-sheba-ok",
};
}
return this.buildMockInquiryRaw(inquiryType, ctx, payload);
}
const INITIAL_DELAY = 500;
const BACKOFF_FACTOR = 2;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const token = await this.getEsgAccessToken();
try {
const response = await firstValueFrom(
this.httpService.post(url, payload, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
timeout: 30000,
}),
);
if (!response?.data) throw new Error("EMPTY_RESPONSE");
return response.data;
} catch (err: any) {
const status = err?.response?.status;
const data = err?.response?.data;
this.logger.error(
`ESG request failed (attempt ${attempt + 1}/${maxRetries}) to ${url} with status ${
status ?? "NO_STATUS"
}`,
data ? JSON.stringify(data) : err?.message || err,
);
if (status === 401) {
this.esgAccessToken = null;
this.esgTokenExpiry = null;
}
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 {
if (!raw) return raw;
if (raw?.success === false) {
this.logger.warn(
"ESG policyByPlate inquiry returned success=false",
raw,
);
return {
Error: {
Message: SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
},
};
}
// Real ESG response wraps payload in { success: true, data: { ... } }.
// Mock data and Tejarat-format fallback are flat objects with no .data wrapper —
// in that case use raw itself so CompanyName / CompanyCode are preserved.
const data =
raw?.data != null && typeof raw?.data === "object" ? raw.data : raw;
return this.mapNewApiResponseToOldFormat({
...data,
companyId: data.CmpCod ?? data.companyId ?? data.CompanyCode,
companyPersianName:
data.CmpNam ?? data.companyPersianName ?? data.CompanyName,
carGrpCod: data.CarGrpCod ?? data.carGrpCod,
usgCod: data.UsgCod ?? data.usgCod,
vehSysCod: data.VehSysCod ?? data.vehSysCod,
mtrnum: data.MtrNum ?? data.mtrnum,
shsNam: data.ShsNum ?? data.ShsNam ?? data.shsNam,
vin: data.VIN ?? data.vin,
ntnlId: data.NtnlId ?? data.ntnlId,
fullname: data.InsNam ?? data.fullname,
ThirdPolicyCode: data.PlcyUnqCod ?? data.ThirdPolicyCode,
LastCompanyDocumentNumber:
data.LastCmpDocNo ?? data.LastCompanyDocumentNumber,
IssueDate: data.HIsuDte ?? data.IssueDate,
StartDate: data.HBgnDte ?? data.StartDate,
EndDate: data.HEndDte ?? data.EndDate,
EdrsJson: data.Edrses ?? data.EdrsJson,
});
}
/**
* Normalises Jalali birth dates to `YYYY-MM-DD` for ESG person inquiry.
* Unlike Tejarat/SandHub personal inquiry, ESG expects Jalali — not Gregorian.
*/
private normalizeJalaliBirthDateForEsg(
input: string | number | null | undefined,
): string | null {
if (input === null || input === undefined) return null;
const raw = typeof input === "number" ? String(input) : String(input).trim();
if (!raw) return null;
let year = 0;
let month = 0;
let day = 0;
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
if (separated) {
year = parseInt(separated[1], 10);
month = parseInt(separated[2], 10);
day = parseInt(separated[3], 10);
} else {
const digits = raw.replace(/\D/g, "");
if (digits.length === 8) {
year = parseInt(digits.slice(0, 4), 10);
month = parseInt(digits.slice(4, 6), 10);
day = parseInt(digits.slice(6, 8), 10);
} else {
return null;
}
}
if (!year || !month || !day || year < 1300) return null;
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return `${year}-${mm}-${dd}`;
}
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> {
return {
firstName: "نام",
lastName: "خانوادگی",
fatherName: "-",
birthCertificateNumber: "-",
nin: nationalCode,
};
}
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) {
this.logger.warn("ESG person inquiry returned success=false", raw);
throw new BadRequestException(
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
);
}
const data = raw?.data ?? {};
return {
firstName: data.Name ?? data.firstName,
lastName: data.Family ?? data.lastName,
fatherName: data.FatherName ?? data.fatherName,
birthCertificateNumber:
data.Shenasnameserial ??
data.ShenasnameNo ??
data.birthCertificateNumber,
nin: data.Nin ?? data.nationalCode ?? data.nin,
fullName: data.fullName,
birthDate: data.BirthDate ?? data.birthDate,
gender: data.Gender,
trackingCode: raw?.trackingCode,
};
}
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
if (raw?.success === false) {
this.logger.warn("ESG sheba inquiry returned success=false", raw);
throw new BadRequestException(
SandHubService.ESG_INQUIRY_UNAVAILABLE_MESSAGE,
);
}
const data = raw?.data ?? {};
return {
ReturnValue: data.ReturnValue ?? true,
HasError: data.HasError ?? false,
Errors: data.Errors ?? {},
Message: raw?.message ?? data.Message,
trackingCode: raw?.trackingCode,
};
}
private async makeTejaratRequest(
url: string,
payload: any,
inquiryType: ExternalInquiryType,
options?: SandHubInquiryOptions,
maxRetries = 2,
) {
if (!(await this.isInquiryLive(inquiryType, options))) {
this.logger.log(`[MOCK] Tejarat POST skipped (${inquiryType}): ${url}`);
const ctx = await this.mockCompanyContext(options);
return this.buildMockInquiryRaw(inquiryType, ctx, payload);
}
const INITIAL_DELAY = 500;
const BACKOFF_FACTOR = 2;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const token = await this.getTejaratAccessToken();
try {
const response = await firstValueFrom(
this.httpService.post(url, payload, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
timeout: 30000,
}),
);
if (!response?.data) throw new Error("EMPTY_RESPONSE");
return response.data;
} catch (err: any) {
const status = err?.response?.status;
const data = err?.response?.data;
this.logger.error(
`Tejarat request failed (attempt ${attempt + 1}/${maxRetries}) to ${url} with status ${
status ?? "NO_STATUS"
}`,
data ? JSON.stringify(data) : err?.message || err,
);
// If unauthorized, clear token and retry once
if (status === 401) {
this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null;
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) {
throw err;
}
}
}
}
/**
* Tejarat block inquiry (replaces SandHub call for V2 flows).
* Returns both raw + mapped (old-format) response.
*/
async getTejaratBlockInquiry(
userDetail: SandHubDetailDto,
options?: SandHubInquiryOptions,
): Promise<{
raw: any;
mapped: any;
}> {
const ctx = await this.mockCompanyContext(options);
if (this.shouldUseEsgInquiryProvider()) {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestPayload = {
nationalCode: String(userDetail.nationalCodeOfInsurer),
plk1: String(userDetail.plate.leftDigits),
plk2: String(userDetail.plate.centerAlphabet),
plk3: String(userDetail.plate.centerDigits),
plksrl: String(userDetail.plate.ir),
};
const requestUrl = `${baseUrl}/inquiry/policyByPlate`;
const live = await this.isInquiryLive("thirdPartyPlate", options);
const raw = live
? await this.makeEsgRequest(
requestUrl,
requestPayload,
"thirdPartyPlate",
options,
)
: this.buildMockPlateInquiryRaw(ctx);
if (!live) {
this.logger.debug(
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
return { raw, mapped };
}
const baseUrl =
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
const requestPayload = {
leftTwoDigits: String(userDetail.plate.leftDigits),
serialLetter: String(userDetail.plate.centerAlphabet),
threeDigits: String(userDetail.plate.centerDigits),
rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer,
};
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
const live = await this.isInquiryLive("thirdPartyPlate", options);
const raw = live
? await this.makeTejaratRequest(
requestUrl,
requestPayload,
"thirdPartyPlate",
options,
)
: this.buildMockPlateInquiryRaw(ctx);
if (!live) {
this.logger.debug(
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const mapped = this.mapNewApiResponseToOldFormat(raw);
return { raw, mapped };
}
async getSandHubDataFromId(sandHubId) {
return await this.sandHubDbService.findOneBySandHubId(sandHubId);
}
async getTejaratCarBodyInquiry(
userDetail: SandHubDetailDto,
options?: SandHubInquiryOptions,
): Promise<{
raw: any;
mapped: Record<string, unknown>;
}> {
const baseUrl =
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
const requestPayload = {
part1: Number(userDetail.plate.leftDigits),
part2: String(userDetail.plate.centerAlphabet),
part3: Number(userDetail.plate.centerDigits),
part4: Number(userDetail.plate.ir),
nationalCode: String(userDetail.nationalCodeOfInsurer),
};
const requestUrl = `${baseUrl}/block-inquiry-tejarat/badane`;
const live = await this.isInquiryLive("carBodyPlate", options);
if (!live) {
this.logger.debug(
`[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`,
);
}
const raw = await this.makeTejaratRequest(
requestUrl,
requestPayload,
"carBodyPlate",
options,
);
const mapped = this.mapCarBodyInquiryResponse(raw);
return { raw, mapped };
}
/**
* Maps the Tejarat car-body (badane) API response to a consistent
* shape that mirrors the third-party inquiry mapped format.
* Callers should always use `mapped` for display; `raw` is kept for audit.
*/
private mapCarBodyInquiryResponse(raw: any): Record<string, unknown> {
if (!raw) return {};
const data = raw?.data ?? raw; // some responses nest under `data`
return {
// Identity
policyNumber: data.printNumber ?? data.insuranceNumber ?? null,
companyId: data.companyId ?? null,
CompanyCode: data.companyId ?? null,
CompanyName: data.companyName ?? data.companyPersianName ?? null,
// Insurer
insurerNationalCode:
data.insurerNationalCode ?? data.ownerNationalCode ?? null,
insurerName: data.insurerName ?? null,
ownerNationalCode: data.ownerNationalCode ?? null,
InsuranceFullName: data.insurerName ?? data.ownerName ?? null,
// Vehicle
motorNumber: data.motorNumber ?? data.EngineNumberField ?? null,
EngineNumberField: data.motorNumber ?? data.EngineNumberField ?? null,
chassisNumber: data.chassisNumber ?? data.ChassisNumberField ?? null,
ChassisNumberField: data.chassisNumber ?? data.ChassisNumberField ?? null,
vin: data.vin ?? data.VinNumberField ?? null,
VinNumberField: data.vin ?? data.VinNumberField ?? null,
vehicleGroupTitle: data.vehicleGroupTitle ?? null,
vehicleSystemTitle: data.vehicleSystemTitle ?? null,
MapTypNam: data.vehicleGroupTitle ?? data.vehicleSystemTitle ?? null,
// Policy dates (Jalali strings from the API)
IssueDate: data.issueDate ?? data.persianStartDate ?? null,
StartDate: data.beginDate ?? data.persianStartDate ?? null,
EndDate: data.endDate ?? data.persianEndDate ?? null,
// Plate
plateTypeTitle: data.plateTypeTitle ?? null,
platePartOne: data.platePartOne ?? null,
platePartThree: data.platePartThree ?? null,
plateSerialNumber: data.plateSerialNumber ?? null,
plateLetterTitle: data.plateLetterTitle ?? null,
// Loss / no-claim history
noLossYearsCount: data.noLossYearsCount ?? null,
lossDocuments: Array.isArray(data.lossDocuments)
? data.lossDocuments
: [],
// Misc
hasEndorsement: data.hasEndorsement ?? null,
previousId: data.previousId ?? null,
// Status passthrough
isSuccess: raw?.isSuccess ?? true,
statusCode: raw?.statusCode ?? 200,
message: raw?.message ?? null,
};
}
private async makeSandHubRequest(
url: string,
payload: any,
inquiryType: ExternalInquiryType,
options?: SandHubInquiryOptions,
maxRetries = 3,
) {
if (!(await this.isInquiryLive(inquiryType, options))) {
this.logger.log(`[MOCK] SandHub POST skipped (${inquiryType}): ${url}`);
const ctx = await this.mockCompanyContext(options);
if (inquiryType === "carBodyPlate") {
return this.buildMockInquiryRaw(inquiryType, ctx, payload);
}
const plateMock =
inquiryType === "thirdPartyPlate"
? this.buildMockPlateInquiryRaw(ctx)
: undefined;
return this.buildMockSandHubResponseForUrl(url, payload, plateMock);
}
const token = await this.getAccessToken();
const INITIAL_DELAY = 1000;
const BACKOFF_FACTOR = 2;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await firstValueFrom(
this.httpService.post(url, payload, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
timeout: 50000,
}),
);
if (!response?.data) throw new Error("EMPTY_RESPONSE");
return response.data;
} catch (err) {
this.handleSandHubError(err, attempt, maxRetries);
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries",
);
}
/**
* Maps the new SandHub API response format to the old format expected by the application
*/
private mapNewApiResponseToOldFormat(newResponse: any): any {
if (!newResponse) return newResponse;
// Map the new field names to the old field names
return {
...newResponse,
// Company information
CompanyCode: newResponse.companyId || newResponse.CompanyCode,
CompanyName: newResponse.companyPersianName || newResponse.CompanyName,
// Vehicle information
MapTypNam: newResponse.vehiclePersianName || newResponse.MapTypNam,
UsageField:
newResponse.persianCarType ||
newResponse.UsageField ||
(newResponse.usgCod === "8" ? "شخصی" : "نامشخص"),
MapUsageName: newResponse.MapUsageName || newResponse.MapUsageName,
// Financial coverage
FinancialCvrCptl:
newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0",
// Dates
IssueDate: newResponse.persianStartDate || newResponse.IssueDate,
EndDate: newResponse.persianEndDate || newResponse.EndDate,
// Insurance details
LastCompanyDocumentNumber:
newResponse.lastCompanyInsuranceNumber ||
newResponse.LastCompanyDocumentNumber ||
newResponse.insuranceNumber,
// Technical details
MtrNum: newResponse.MtrNum || newResponse.mtrnum,
ShsNum:
newResponse.ShsNum ||
newResponse.shsNam ||
newResponse.ChassisNumberField,
vin: newResponse.vin || newResponse.VinNumberField || newResponse.vin,
VinNumberField: newResponse.vin || newResponse.VinNumberField,
ChassisNumberField: newResponse.ChassisNumberField || newResponse.shsNam,
EngineNumberField: newResponse.EngineNumberField || newResponse.mtrnum,
ModelField: newResponse.ModelField || newResponse.ModelField,
// Discount information
DisFnYrPrcnt: newResponse.DisFnYrPrcnt || newResponse.disFnYrPrcnt || "0",
DisLfYrPrcnt: newResponse.DisLfYrPrcnt || newResponse.disLfYrPrcnt || "0",
DisPrsnYrPrcnt:
newResponse.DisPrsnYrPrcnt || newResponse.disPrsnYrPrcnt || "0",
DisFnYrNum: newResponse.DisFnYrNum || newResponse.disFnYrNum,
DisLfYrNum: newResponse.DisLfYrNum || newResponse.disLfYrNum,
DisPrsnYrNum: newResponse.DisPrsnYrNum || newResponse.disPrsnYrNum,
// Usage and vehicle system codes
UsageCode: newResponse.UsageCode || newResponse.usgCod,
VehicleSystemCode: newResponse.VehicleSystemCode || newResponse.vehSysCod,
CarGroupCode: newResponse.CarGroupCode || newResponse.carGrpCod,
// Policy information
ThirdPolicyCode:
newResponse.ThirdPolicyCode || newResponse.ThirdPolicyCode,
// Endorsement data
EdrsJson:
newResponse.EdrsJson ||
(newResponse.edrSes ? JSON.stringify(newResponse.edrSes) : undefined),
// Insurance fullname
InsuranceFullName: newResponse.InsuranceFullName || newResponse.fullname,
// National code
nationalCode: newResponse.nationalCode || newResponse.ntnlId,
};
}
async getSandHubResponse(
userDetail: SandHubDetailDto,
options?: SandHubInquiryOptions,
) {
try {
const requestPayload = {
leftTwoDigits: String(userDetail.plate.leftDigits),
serialLetter: String(userDetail.plate.centerAlphabet),
threeDigits: String(userDetail.plate.centerDigits),
rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer,
};
const base = process.env.SANHUB_BASE_URL ?? "";
const requestUrl = `${base}/block-inquiry-tejarat`;
let response: any;
if (await this.isInquiryLive("thirdPartyPlate", options)) {
response = await this.makeSandHubRequest(
requestUrl,
requestPayload,
"thirdPartyPlate",
options,
);
} else {
this.logger.debug(
`[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`,
);
const ctx = await this.mockCompanyContext(options);
response = this.buildMockPlateInquiryRaw(ctx);
}
const result = this.mapNewApiResponseToOldFormat(response);
// if (result.usgCod !== "8") {
// throw new Error("خودرو شما شخصی / سواری نمی باشد")
// }
return result;
} catch (err) {
throw new Error(err);
}
}
/**
* Personal identity check.
* - CLIENT_ID=8 (Parsian/ESG): Jalali birth date sent as-is to `/inquiry/person`.
* - Other tenants: Tejarat/SandHub hub with Gregorian conversion.
*/
async getPersonalInquiry(
nationalCode: string,
birthDate: number | string,
options?: SandHubInquiryOptions,
) {
try {
if (this.shouldUseEsgInquiryProvider()) {
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").`,
);
}
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestPayload = {
nationalCode: String(nationalCode),
birthDate: jalaliBirthDate,
dateHasPostfix: 0,
};
const requestUrl = `${baseUrl}/inquiry/person`;
const live = await this.isInquiryLive("personalIdentity", options);
if (!live) {
this.logger.debug(
`[MOCK] getEsgPersonInquiry nationalCode=${nationalCode} birthDate=${jalaliBirthDate}`,
);
return this.getDefaultMockPersonInquiry(String(nationalCode));
}
const raw = await this.makeEsgRequest(
requestUrl,
requestPayload,
"personalIdentity",
options,
);
return this.mapEsgPersonInquiryToOldFormat(raw);
}
const requestUrl = `${process.env.SANHUB_BASE_URL}/personal-inquiry/tejarat-no`;
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").`,
);
}
const requestPayload = {
nationalCode,
birthdate: gregorianBirthdate,
};
const response = await this.makeSandHubRequest(
requestUrl,
requestPayload,
"personalIdentity",
options,
);
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.",
);
}
return response.data;
} catch (err) {
if (
err instanceof BadRequestException ||
err instanceof NotFoundException
) {
throw err;
}
throw new Error(`Error in finding personal inquiry: ${err}`);
}
}
async getDrivingLicenseInfo(
nationalCode: string,
driverLicenseNumber: string,
options?: SandHubInquiryOptions,
) {
const requestUrl = `${process.env.SANHUB_BASE_URL}/driver-license-check`;
const requestPayload = {
driverLicenseNumber,
nationalCode,
};
this.logger.log(
`Fetching driving license info for national code: ${nationalCode}`,
);
try {
const response = await this.makeSandHubRequest(
requestUrl,
requestPayload,
"drivingLicense",
options,
);
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}`);
}
}
async getCarOwnershipInfo(
plate: any,
nationalCode: string,
options?: SandHubInquiryOptions,
) {
try {
const requestUrl = `${process.env.SANHUB_BASE_URL}/ownership`;
const requestPayload = {
Plk1: String(plate.leftDigits),
Plk2: String(plate.centerAlphabet),
Plk3: String(plate.centerDigits),
plkSrl: String(plate.ir),
nationalCode: nationalCode,
};
this.logger.log(
`Checking car ownership for national code: ${nationalCode}`,
);
const response = await this.makeSandHubRequest(
requestUrl,
requestPayload,
"carOwnership",
options,
);
// Check the 'IsSuccess' field in the nested 'data' object.
if (response?.data?.IsSuccess === false) {
this.logger.warn(
`Car ownership check failed for national code ${nationalCode}. Response:`,
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}`);
}
}
async getShebaValidation(
nationalId: string,
shebaId: string,
options?: SandHubInquiryOptions,
) {
try {
if (this.shouldUseEsgInquiryProvider()) {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestPayload = {
accountOwnerType: "1",
nationalCode: String(nationalId),
legalId: "",
sheba: String(shebaId),
};
const requestUrl = `${baseUrl}/inquiry/sheba`;
const live = await this.isInquiryLive("sheba", options);
this.logger.log(
`Validating Sheba ID via ESG for national code: ${nationalId}`,
);
if (!live) {
this.logger.debug(
`[MOCK] getEsgShebaInquiry nationalCode=${nationalId} sheba=${shebaId}`,
);
return {
ReturnValue: true,
HasError: false,
Message: "mock-sheba-ok",
};
}
const raw = await this.makeEsgRequest(
requestUrl,
requestPayload,
"sheba",
options,
);
const response = this.mapEsgShebaInquiryToOldFormat(raw);
if (response?.ReturnValue === false || response?.HasError === true) {
this.logger.warn(
`Sheba validation failed for national code ${nationalId}. Response:`,
response,
);
throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
);
}
return response;
}
const requestUrl = `${process.env.SANHUB_BASE_URL}/sheba/sheba-tejaratno`;
const requestPayload = {
AccountOwnerType: "1",
NationalId: nationalId,
ShebaId: shebaId,
};
this.logger.log(`Validating Sheba ID for national code: ${nationalId}`);
const response = await this.makeSandHubRequest(
requestUrl,
requestPayload,
"sheba",
options,
);
if (response?.ReturnValue === false || response?.HasError === true) {
this.logger.warn(
`Sheba validation failed for national code ${nationalId}. Response:`,
response,
);
throw new BadRequestException(
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
);
}
return response;
} catch (err) {
if (err instanceof BadRequestException) {
throw err;
}
throw new Error(`Error in matching sheba validation: ${err}`);
}
}
private handleSandHubError(
err: any,
attempt: number,
maxRetries: number,
): void {
if (err.response) {
this.logger.error(
`❌ SandHub responded with status ${err.response.status} on attempt ${attempt + 1}:`,
JSON.stringify(err.response.data),
);
if (err.response.status === 400) {
throw new BadGatewayException(
`SandHub rejected the request with a 400 Bad Request. Details: ${JSON.stringify(err.response.data)}`,
);
}
} else {
this.logger.error(
`❌ Network error during SandHub request (attempt ${attempt + 1}):`,
err.message,
);
}
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");
}
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) {
throw new BadGatewayException(
"Failed to fetch data from SandHub after multiple retries",
err.message,
);
}
}
async sandHubDocumentCreator(
userId: string,
requestId: string,
doc: Partial<SandHubModel>,
) {
const existingDoc = await this.sandHubDbService.findOneByRequestId(
requestId,
userId,
);
if (!existingDoc) {
return await this.sandHubDbService.create({ ...doc, userId, requestId });
} else {
const updatePayload = { ...existingDoc, ...doc };
return await this.sandHubDbService.findOneByRequestIdAndUpdate(
requestId,
userId,
updatePayload,
);
}
}
}