diff --git a/scripts/refresh-blame-inquiries.js b/scripts/refresh-blame-inquiries.js new file mode 100644 index 0000000..38745c8 --- /dev/null +++ b/scripts/refresh-blame-inquiries.js @@ -0,0 +1,606 @@ +#!/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)); +}