#!/usr/bin/env node /* * One-time script to replace mocked blameCases party vehicle inquiry data with * Tejarat block inquiry responses. * * Defaults to DRY_RUN=true. Set DRY_RUN=false to write changes. */ const fs = require("fs"); const path = require("path"); const mongoose = require("mongoose"); const loadedEnvFiles = loadEnvFiles(process.env.ENV_FILE || ".env"); const LETTER_TO_NUMBER = { "الف": 1, "ب": 2, "ت": 3, "ج": 4, "د": 5, "س": 6, "ص": 7, "ط": 8, "ع": 9, "ق": 10, "ل": 11, "م": 12, "ن": 13, "و": 14, "ه": 15, "ی": 16, "ر": 17, "ک": 18, "ژ": 19, "پ": 20, "ظ": 24, "ض": 25, "ز": 41, "ش": 42, "گ": 43, "ث": 44, D: 45, S: 46, "ح": 47, "ف": 48, }; const NUMBER_TO_LETTER = Object.fromEntries( Object.entries(LETTER_TO_NUMBER).map(([letter, number]) => [String(number), letter]), ); const config = { mongoUri: requiredEnv("MONGO_URL", "MONGODB_URI", "DATABASE_URL"), collectionName: process.env.BLAME_COLLECTION || "blameCases", mongoDbName: process.env.MONGO_DB_NAME || "", thirdPartyUrl: process.env.TEJARAT_THIRD_PARTY_URL || "http://82.99.202.245:3027/block-inquiry-tejarat", carBodyUrl: process.env.TEJARAT_CAR_BODY_URL || "http://82.99.202.245:3027/block-inquiry-tejarat/badane", thirdPartyToken: process.env.TEJARAT_THIRD_PARTY_TOKEN || process.env.TEJARAT_TOKEN || "", carBodyToken: process.env.TEJARAT_CAR_BODY_TOKEN || process.env.TEJARAT_TOKEN || "", rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE || 5), retryEnabled: String(process.env.RETRY_ENABLED ?? "true").toLowerCase() !== "false", retryCount: Number(process.env.RETRY_COUNT || 3), retryDelayMs: Number(process.env.RETRY_DELAY_MS || 2000), dryRun: String(process.env.DRY_RUN ?? "true").toLowerCase() !== "false", limit: process.env.LIMIT ? Number(process.env.LIMIT) : 0, publicId: process.env.PUBLIC_ID || "", }; let lastRequestAt = 0; main().catch(async (error) => { console.error("[fatal]", error && error.stack ? error.stack : error); await mongoose.disconnect().catch(() => undefined); process.exitCode = 1; }); async function main() { validateConfig(); console.log("script runned successfully"); console.log( "[config] envFiles=" + (loadedEnvFiles.length ? loadedEnvFiles.join(",") : "none") + ", dbName=" + (config.mongoDbName || "from-url-or-driver-default") + ", collection=" + config.collectionName + ", dryRun=" + config.dryRun + ", rateLimitPerMinute=" + config.rateLimitPerMinute + ", retryEnabled=" + config.retryEnabled + ", retryCount=" + config.retryCount, ); await mongoose.connect(config.mongoUri, { autoIndex: false, dbName: config.mongoDbName || undefined, }); const collection = mongoose.connection.collection(config.collectionName); const query = { type: { $in: ["THIRD_PARTY", "CAR_BODY"] }, }; if (config.publicId) query.publicId = config.publicId; const totalDocs = await collection.countDocuments(query); console.log(`total docs that we have to edit: ${totalDocs}`); const cursor = collection .find(query, { projection: { publicId: 1, requestNo: 1, type: 1, parties: 1, }, }) .sort({ createdAt: 1, _id: 1 }); if (config.limit > 0) cursor.limit(config.limit); const summary = { docsSeen: 0, docsChanged: 0, partiesInquired: 0, partiesSkipped: 0, partiesFailed: 0, }; for await (const doc of cursor) { summary.docsSeen += 1; const label = doc.publicId || doc.requestNo || String(doc._id); const requestId = String(doc._id); console.log(`currently inquiry for doc with ${label} and requestId ${requestId}`); const parties = Array.isArray(doc.parties) ? clone(doc.parties) : []; let docChanged = false; for (let index = 0; index < parties.length; index += 1) { const party = parties[index]; const partyLabel = `${label} parties[${index}] role=${party && party.role ? party.role : "-"}`; const input = buildInquiryInput(doc, party); if (!input.ok) { summary.partiesSkipped += 1; console.warn(`[skip] ${partyLabel}: ${input.reason}`); continue; } console.log(`[request] ${partyLabel} body=${JSON.stringify(input.body)}`); try { const inquiryResponse = await inquiryWithRetry(doc.type, input.body); const successful = isInquirySuccessful(doc.type, inquiryResponse); console.log( `[response] ${partyLabel} successful=${successful} body=${JSON.stringify(inquiryResponse)}`, ); if (!successful) { summary.partiesFailed += 1; continue; } parties[index] = applyInquiryToParty(doc.type, party, inquiryResponse, input.plate); summary.partiesInquired += 1; docChanged = true; } catch (error) { summary.partiesFailed += 1; console.error(`[error] ${partyLabel}: ${error.message}`); } } if (!docChanged) { console.log(`[doc] ${label} no changes`); continue; } if (config.dryRun) { console.log(`[dry-run] ${label} would update parties array`); continue; } const result = await collection.updateOne( { _id: doc._id }, { $set: { parties, updatedAt: new Date(), }, }, ); summary.docsChanged += result.modifiedCount; console.log(`[update] ${label} matched=${result.matchedCount} modified=${result.modifiedCount}`); } await mongoose.disconnect(); console.log(`[done] ${JSON.stringify(summary)}`); } function buildInquiryInput(doc, party) { if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" }; if (!party.vehicle || typeof party.vehicle !== "object") { return { ok: false, reason: "party.vehicle is missing" }; } const plate = extractPlate(party); const nationalCode = cleanString(party.person && party.person.nationalCodeOfInsurer) || cleanString(party.person && party.person.nationalCodeOfDriver); if (!plate) return { ok: false, reason: "Plk1/Plk2/Plk3/PlkSrl not found" }; if (!nationalCode) return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" }; const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2); if (!serialLetter) return { ok: false, reason: `no Persian letter mapping for Plk2=${plate.Plk2}` }; if (doc.type === "CAR_BODY") { return { ok: true, plate, body: { part1: toNumber(plate.Plk1), part2: serialLetter, part3: toNumber(plate.Plk3), part4: toNumber(plate.PlkSrl), nationalCode, }, }; } if (doc.type === "THIRD_PARTY") { return { ok: true, plate, body: { leftTwoDigits: String(plate.Plk1), serialLetter, threeDigits: String(plate.Plk3), rightTwoDigits: String(plate.PlkSrl), nationalCode, }, }; } return { ok: false, reason: `unsupported type=${doc.type}` }; } function parsePlateId(plateId) { const value = cleanString(plateId); if (!value) return null; const parts = value.split("-").map((part) => normalizePlateNumber(part)); if (parts.length !== 4) return null; const thirdPartIsLetter = LETTER_TO_NUMBER[parts[2]] !== undefined; const fourthPartIsLetter = LETTER_TO_NUMBER[parts[3]] !== undefined; if (thirdPartIsLetter) { return { Plk1: parts[1], Plk2: String(LETTER_TO_NUMBER[parts[2]]), Plk3: parts[3], PlkSrl: parts[0], }; } if (fourthPartIsLetter) { return { Plk1: parts[2], Plk2: String(LETTER_TO_NUMBER[parts[3]]), Plk3: parts[1], PlkSrl: parts[0], }; } return null; } function extractPlate(party) { const plateFromPlateId = parsePlateId(party.vehicle && party.vehicle.plateId); if (plateFromPlateId) return plateFromPlateId; const candidates = [ party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.mapped, party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.raw, party.vehicle && party.vehicle.inquiry, party.vehicle, ].filter(Boolean); for (const candidate of candidates) { const Plk1 = firstPresent(candidate.Plk1, candidate.platePartOne); const Plk2 = firstPresent(candidate.Plk2, candidate.plateLetterid, candidate.plateLetterId); const Plk3 = firstPresent(candidate.Plk3, candidate.platePartThree); const PlkSrl = firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber); if ( Plk1 !== undefined && Plk2 !== undefined && Plk3 !== undefined && PlkSrl !== undefined ) { return { Plk1: normalizePlateNumber(Plk1), Plk2: normalizePlateNumber(Plk2), Plk3: normalizePlateNumber(Plk3), PlkSrl: normalizePlateNumber(PlkSrl), }; } } return null; } async function inquiryWithRetry(type, body) { const url = type === "CAR_BODY" ? config.carBodyUrl : config.thirdPartyUrl; const token = type === "CAR_BODY" ? config.carBodyToken : config.thirdPartyToken; let lastError; const maxAttempts = config.retryEnabled ? config.retryCount : 1; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { await waitForRateLimit(); try { console.log("[http] " + type + " attempt=" + attempt + "/" + maxAttempts); return await postJson(url, token, body); } catch (error) { lastError = error; console.error(`[retry] ${type} attempt=${attempt} failed: ${error.message}`); if (attempt < maxAttempts) { await sleep(config.retryDelayMs * attempt); } } } throw lastError; } async function postJson(url, token, body) { const response = await fetch(url, { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json", }, body: JSON.stringify(body), }); const text = await response.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = text; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`); } return data; } function isInquirySuccessful(type, response) { if (type === "CAR_BODY") return response && response.isSuccess === true && response.data; return response && response.resultStatus === true; } function applyInquiryToParty(type, party, response, originalPlate) { const next = clone(party); if (!next.vehicle) next.vehicle = {}; if (!next.insurance) next.insurance = {}; const mapped = type === "CAR_BODY" ? normalizeCarBodyResponse(response, originalPlate) : normalizeThirdPartyResponse(response, originalPlate); const plateId = buildPlateId(originalPlate); next.vehicle.plateId = plateId || next.vehicle.plateId; next.vehicle.inquiry = { source: type === "CAR_BODY" ? "TEJARAT_CAR_BODY_BLOCK_INQUIRY" : "TEJARAT_BLOCK_INQUIRY", raw: type === "CAR_BODY" ? normalizeCarBodyRaw(response, originalPlate) : response, mapped, refreshedAt: new Date().toISOString(), }; if (type === "CAR_BODY") { next.vehicle.inquiry.responseMeta = { isSuccess: response.isSuccess, statusCode: response.statusCode, message: response.message, }; } if (type === "CAR_BODY") { next.vehicle.name = mapped.vehicleSystemTitle || next.vehicle.name; next.vehicle.type = mapped.vehicleGroupTitle || next.vehicle.type; next.insurance.policyNumber = mapped.printNumber || next.insurance.policyNumber; next.insurance.company = mapped.companyName || next.insurance.company; next.insurance.startDate = mapped.beginDate || next.insurance.startDate; next.insurance.endDate = mapped.endDate || next.insurance.endDate; next.insurance.carBodyInsurance = { policyNumber: mapped.printNumber, startDate: mapped.beginDate, endDate: mapped.endDate, insurerCompany: mapped.companyName, coverages: Array.isArray(mapped.coverages) ? mapped.coverages : [], }; return next; } next.vehicle.name = mapped.vehiclePersianName || mapped.MapTypNam || "اطلاعات این گزینه در استعلام موجود نیست"; next.vehicle.type = mapped.persianCarType || mapped.MapUsageName || next.vehicle.type; next.insurance.policyNumber = mapped.insuranceNumber || next.insurance.policyNumber; next.insurance.company = mapped.companyPersianName || next.insurance.company; next.insurance.financialCeiling = mapped.financeCoverage || next.insurance.financialCeiling; next.insurance.startDate = mapped.persianStartDate || next.insurance.startDate; next.insurance.endDate = mapped.persianEndDate || next.insurance.endDate; return next; } function buildPlateId(plate) { if (!plate) return ""; const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2); if (!serialLetter) return ""; return ( cleanString(plate.PlkSrl) + "-" + cleanString(plate.Plk1) + "-" + serialLetter + "-" + cleanString(plate.Plk3) ); } function normalizeThirdPartyResponse(response, originalPlate) { return { ...response, Plk1: toNumber(originalPlate.Plk1), Plk2: toNumber(originalPlate.Plk2), Plk3: toNumber(originalPlate.Plk3), PlkSrl: toNumber(originalPlate.PlkSrl), CompanyName: response.companyPersianName, CompanyCode: response.companyId, LastCompanyDocumentNumber: response.insuranceNumber, FinancialCvrCptl: response.financeCoverage, IssueDate: response.hIsuDte || response.persianStartDate, SatrtDate: response.persianStartDate, EndDate: response.persianEndDate, MapTypNam: response.vehiclePersianName, MapUsageName: response.MapUsageName, }; } function normalizeCarBodyRaw(response, originalPlate) { const data = response.data || {}; return { ...data, Plk1: toNumber(originalPlate.Plk1), Plk2: toNumber(originalPlate.Plk2), Plk3: toNumber(originalPlate.Plk3), PlkSrl: toNumber(originalPlate.PlkSrl), }; } function normalizeCarBodyResponse(response, originalPlate) { const data = response.data || {}; return { ...data, Plk1: toNumber(originalPlate.Plk1), Plk2: toNumber(originalPlate.Plk2), Plk3: toNumber(originalPlate.Plk3), PlkSrl: toNumber(originalPlate.PlkSrl), CompanyName: data.companyName, CompanyCode: data.companyId, LastCompanyDocumentNumber: data.printNumber, IssueDate: data.issueDate, SatrtDate: data.beginDate, EndDate: data.endDate, MtrNum: data.motorNumber, ShsNum: data.chassisNumber, VinNumberField: data.vin, MapTypNam: data.vehicleSystemTitle, }; } async function waitForRateLimit() { const minDelayMs = Math.ceil(60000 / config.rateLimitPerMinute); const elapsed = Date.now() - lastRequestAt; if (lastRequestAt > 0 && elapsed < minDelayMs) { const waitMs = minDelayMs - elapsed; console.log(`[rate-limit] waiting ${waitMs}ms`); await sleep(waitMs); } lastRequestAt = Date.now(); } function validateConfig() { if (!config.mongoUri) throw new Error("MONGO_URL is required"); if (!Number.isFinite(config.rateLimitPerMinute) || config.rateLimitPerMinute <= 0) { throw new Error("RATE_LIMIT_PER_MINUTE must be a positive number"); } if (!Number.isFinite(config.retryCount) || config.retryCount <= 0) { throw new Error("RETRY_COUNT must be a positive number"); } if (!config.thirdPartyToken) { throw new Error("TEJARAT_THIRD_PARTY_TOKEN or TEJARAT_TOKEN is required"); } if (!config.carBodyToken) { throw new Error("TEJARAT_CAR_BODY_TOKEN or TEJARAT_TOKEN is required"); } } function loadEnvFiles(filePath) { const candidates = path.isAbsolute(filePath) ? [filePath] : [ path.resolve(process.cwd(), filePath), path.resolve(__dirname, "..", filePath), ]; const loaded = []; for (const candidate of [...new Set(candidates)]) { if (!fs.existsSync(candidate)) continue; loadEnvFile(candidate); loaded.push(candidate); } return loaded; } function loadEnvFile(filePath) { const resolved = path.resolve(process.cwd(), filePath); if (!fs.existsSync(resolved)) return; const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const equalIndex = trimmed.indexOf("="); if (equalIndex === -1) continue; const key = trimmed.slice(0, equalIndex).trim(); let value = trimmed.slice(equalIndex + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (key && process.env[key] === undefined) process.env[key] = value; } } function requiredEnv(...names) { for (const name of names) { if (process.env[name]) return process.env[name]; } return ""; } function firstPresent(...values) { return values.find((value) => value !== undefined && value !== null && value !== ""); } function normalizePlateNumber(value) { const cleaned = normalizeDigits(cleanString(value)); return cleaned === "" ? value : cleaned; } function normalizeDigits(value) { return cleanString(value).replace(/./g, (char) => { const code = char.charCodeAt(0); if (code >= 0x06f0 && code <= 0x06f9) return String(code - 0x06f0); if (code >= 0x0660 && code <= 0x0669) return String(code - 0x0660); return char; }); } function toNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : value; } function cleanString(value) { return value === undefined || value === null ? "" : String(value).trim(); } function clone(value) { return JSON.parse(JSON.stringify(value)); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }