import { BadRequestException, Injectable, Logger, NotFoundException, } from "@nestjs/common"; import { Types } from "mongoose"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { BlameRequestDbService } from "./entities/db-service/blame-request.db.service"; import { PartyRole } from "./entities/schema/partyRole.enum"; import { ReinquiryCaseResultDto, ReinquiryInquiriesDto, ReinquiryInquiriesResponseDto, ReinquiryPartyResultDto, } from "./dto/reinquiry-inquiries.dto"; type PlateParts = { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number; }; @Injectable() export class InquiryRefreshService { private readonly logger = new Logger(InquiryRefreshService.name); private lastRequestAt = 0; constructor( private readonly blameRequestDbService: BlameRequestDbService, private readonly claimCaseDbService: ClaimCaseDbService, private readonly sandHubService: SandHubService, ) {} async reinquiryInquiries( body: ReinquiryInquiriesDto, ): Promise { const dryRun = body.dryRun === true; const roles = body.roles?.length ? body.roles : [PartyRole.FIRST, PartyRole.SECOND]; const limit = body.limit && body.limit > 0 ? body.limit : 0; if (!body.publicId && !body.blameRequestId && limit === 0) { throw new BadRequestException( "Provide publicId, blameRequestId, or limit for bulk refresh.", ); } const filter: Record = { type: { $in: [BlameRequestType.THIRD_PARTY, BlameRequestType.CAR_BODY] }, }; if (body.publicId) filter.publicId = body.publicId; if (body.blameRequestId) { if (!Types.ObjectId.isValid(body.blameRequestId)) { throw new BadRequestException("Invalid blameRequestId"); } filter._id = new Types.ObjectId(body.blameRequestId); } let docs = await this.blameRequestDbService.find(filter, { lean: true }); if (!docs.length) { throw new NotFoundException("No matching blame cases found"); } docs = limit > 0 ? docs.slice(0, limit) : docs; const summary: ReinquiryInquiriesResponseDto = { dryRun, docsSeen: docs.length, blameDocsUpdated: 0, claimDocsUpdated: 0, results: [], }; for (const doc of docs) { const caseResult = await this.refreshBlameCase( doc as Record, roles, dryRun, ); summary.results.push(caseResult); if (caseResult.blameUpdated) summary.blameDocsUpdated += 1; summary.claimDocsUpdated += caseResult.claimsUpdated; } return summary; } private async refreshBlameCase( doc: Record, roles: PartyRole[], dryRun: boolean, ): Promise { const label = doc.publicId || doc.requestNo || String(doc._id); const parties = Array.isArray(doc.parties) ? [...doc.parties] : []; const inquiries = doc.inquiries && typeof doc.inquiries === "object" ? { ...doc.inquiries } : {}; let blameChanged = false; const partyResults: ReinquiryPartyResultDto[] = []; for (const role of roles) { const index = parties.findIndex((party) => party?.role === role); if (index === -1) { partyResults.push({ role, thirdParty: { ok: false, message: "party not found" }, person: { ok: false, message: "party not found" }, }); continue; } const party = parties[index]; const partyResult = await this.refreshPartyInquiries( doc, party, role, inquiries, dryRun, label, ); partyResults.push(partyResult.result); if (partyResult.partyChanged) { parties[index] = partyResult.party; blameChanged = true; } if (partyResult.inquiriesChanged) { blameChanged = true; } } let claimsUpdated = 0; if (blameChanged && !dryRun) { await this.persistBlameCaseUpdates(doc._id, roles, parties, inquiries); claimsUpdated = await this.syncInquiriesToClaims( String(doc._id), inquiries, ); } else if (blameChanged && dryRun) { const linkedClaims = await this.claimCaseDbService.find({ blameRequestId: new Types.ObjectId(String(doc._id)), }); claimsUpdated = linkedClaims.length; this.logger.log(`[dry-run] ${label} would update blame + ${claimsUpdated} claim(s)`); } return { blameRequestId: String(doc._id), publicId: doc.publicId, blameUpdated: blameChanged, claimsUpdated, ...(dryRun && blameChanged ? { inquiriesPreview: { thirdParty: inquiries.thirdParty, person: inquiries.person, }, } : {}), parties: partyResults, }; } private async refreshPartyInquiries( doc: Record, party: Record, role: PartyRole, inquiries: Record, dryRun: boolean, caseLabel: string, ): Promise<{ party: Record; partyChanged: boolean; inquiriesChanged: boolean; result: ReinquiryPartyResultDto; }> { const result: ReinquiryPartyResultDto = { role }; let nextParty = party; let partyChanged = false; let inquiriesChanged = false; const plate = this.resolvePartyPlate(party); const nationalCode = this.cleanString(party?.person?.nationalCodeOfInsurer) || this.cleanString(party?.person?.nationalCodeOfDriver); const birthDate = party?.person?.insurerBirthday ?? party?.person?.driverBirthday ?? party?.person?.birthday; const inquiryClientId = party?.person?.clientId ? String(party.person.clientId) : undefined; const inquiryOptions = inquiryClientId ? { clientId: inquiryClientId } : undefined; result.input = { plateId: party?.vehicle?.plateId, ...(plate ? { plate } : {}), ...(nationalCode ? { nationalCode } : {}), ...(birthDate !== null && birthDate !== undefined ? { birthDate } : {}), }; if (dryRun) { this.logger.log( `[dry-run] ${caseLabel} ${role} input=${JSON.stringify(result.input)}`, ); } if (!plate || !nationalCode) { result.thirdParty = { ok: false, message: !plate ? "plate not found on party" : "nationalCodeOfInsurer/nationalCodeOfDriver missing", }; } else { await this.waitForRateLimit(); try { const inquiry = await this.sandHubService.getTejaratBlockInquiry( { plate: { ...plate, nationalCode, }, nationalCodeOfInsurer: nationalCode, }, inquiryOptions, ); if (inquiry.mapped?.Error) { this.recordPartyInquiry(inquiries, "thirdParty", role, false, { source: "TEJARAT_BLOCK_INQUIRY", raw: inquiry.raw, mapped: inquiry.mapped, }); inquiriesChanged = true; result.thirdParty = { ok: false, message: inquiry.mapped.Error.Message || "third-party inquiry error", }; } else { nextParty = this.applyThirdPartyToParty(nextParty, inquiry.raw, inquiry.mapped); partyChanged = true; this.recordPartyInquiry(inquiries, "thirdParty", role, true, { source: "TEJARAT_BLOCK_INQUIRY", raw: inquiry.raw, mapped: inquiry.mapped, refreshedAt: new Date().toISOString(), }); inquiriesChanged = true; result.thirdParty = { ok: true }; if (dryRun) { result.preview = { ...(result.preview || {}), thirdParty: inquiries.thirdParty?.data?.[role], party: { vehicle: { name: nextParty.vehicle?.name, type: nextParty.vehicle?.type, plateId: nextParty.vehicle?.plateId, }, insurance: { policyNumber: nextParty.insurance?.policyNumber, company: nextParty.insurance?.company, financialCeiling: nextParty.insurance?.financialCeiling, startDate: nextParty.insurance?.startDate, endDate: nextParty.insurance?.endDate, }, }, }; } } } catch (error: any) { this.recordPartyInquiry(inquiries, "thirdParty", role, false, {}, error); inquiriesChanged = true; result.thirdParty = { ok: false, message: error?.message || String(error), }; } } if (!nationalCode || birthDate === null || birthDate === undefined) { result.person = { ok: false, message: !nationalCode ? "nationalCodeOfInsurer/nationalCodeOfDriver missing" : "insurerBirthday/driverBirthday missing", }; } else { await this.waitForRateLimit(); try { const personData = await this.sandHubService.getPersonalInquiry( nationalCode, birthDate, inquiryOptions, ); this.recordPartyInquiry(inquiries, "person", role, true, personData); inquiriesChanged = true; result.person = { ok: true }; if (dryRun) { result.preview = { ...(result.preview || {}), person: inquiries.person?.data?.[role], }; this.logger.log( `[dry-run] ${caseLabel} ${role} person preview=${JSON.stringify(result.preview.person)}`, ); } } catch (error: any) { this.recordPartyInquiry(inquiries, "person", role, false, {}, error); inquiriesChanged = true; result.person = { ok: false, message: error?.message || String(error), }; } } if (dryRun && result.preview?.thirdParty) { this.logger.log( `[dry-run] ${caseLabel} ${role} thirdParty mapped.company=${(result.preview.thirdParty as any)?.mapped?.CompanyName ?? "-"}`, ); } return { party: nextParty, partyChanged, inquiriesChanged, result }; } private applyThirdPartyToParty( party: Record, raw: Record, mapped: Record, ): Record { const next = { ...party }; next.vehicle = { ...(next.vehicle || {}) }; next.insurance = { ...(next.insurance || {}) }; const existingCarBody = next.vehicle.inquiry?.carBody; next.vehicle.inquiry = { source: "TEJARAT_BLOCK_INQUIRY", raw, mapped, refreshedAt: new Date().toISOString(), ...(existingCarBody ? { carBody: existingCarBody } : {}), }; const vehicleName = this.resolveVehicleName(mapped, raw); if (vehicleName) { next.vehicle.name = vehicleName; } const vehicleType = this.resolveVehicleType(mapped, raw); if (vehicleType) { next.vehicle.type = vehicleType; } next.insurance.policyNumber = mapped.LastCompanyDocumentNumber || mapped.insuranceNumber || next.insurance.policyNumber; next.insurance.company = mapped.companyPersianName || mapped.CompanyName || next.insurance.company; const financialCeiling = mapped.financeCoverage ?? mapped.FinancialCvrCptl ?? next.insurance.financialCeiling; next.insurance.financialCeiling = financialCeiling !== undefined && financialCeiling !== null ? String(financialCeiling) : next.insurance.financialCeiling; next.insurance.startDate = mapped.persianStartDate || mapped.IssueDate || mapped.SatrtDate || next.insurance.startDate; next.insurance.endDate = mapped.persianEndDate || mapped.EndDate || next.insurance.endDate; return next; } /** Same sources as FIRST_INITIAL_FORM + common Tejarat/ESG field aliases */ private resolveVehicleName( mapped: Record, raw: Record, ): string | undefined { return this.firstNonEmptyString( mapped?.vehiclePersianName, mapped?.MapTypNam, mapped?.TypeNameCii, mapped?.CarName, raw?.vehiclePersianName, raw?.MapTypNam, raw?.TypeNameCii, raw?.CarName, ); } /** Matches request-management: `${UsageField} / ${MapUsageName}` */ private resolveVehicleType( mapped: Record, raw: Record, ): string | undefined { const usageField = this.firstNonEmptyString( mapped?.UsageField, mapped?.persianCarType, raw?.UsageField, raw?.UsageNameCii, ); const usageName = this.firstNonEmptyString( mapped?.MapUsageName, raw?.MapUsageName, ); if (usageField && usageName) { return `${usageField} / ${usageName}`; } return usageField || usageName; } private firstNonEmptyString(...values: unknown[]): string | undefined { for (const value of values) { if (value === undefined || value === null) continue; const text = String(value).trim(); if (text) return text; } return undefined; } /** * Mutate an existing Mongoose document in place instead of $set on a lean-cloned * parties array (which causes "Cast to embedded failed" on ObjectId sub-fields). */ private async persistBlameCaseUpdates( blameId: string | Types.ObjectId, roles: PartyRole[], updatedParties: Record[], inquiries: Record, ): Promise { const blameDoc = await this.blameRequestDbService.findById(blameId); if (!blameDoc) { throw new NotFoundException(`Blame case ${blameId} not found`); } for (const role of roles) { const memParty = updatedParties.find((party) => party?.role === role); const docIdx = blameDoc.parties.findIndex((party) => party?.role === role); if (!memParty || docIdx === -1) continue; const party = blameDoc.parties[docIdx]; if (!party.vehicle) party.vehicle = {} as any; if (!party.insurance) party.insurance = {} as any; if (memParty.vehicle?.inquiry) { party.vehicle.inquiry = memParty.vehicle.inquiry; } if (memParty.vehicle?.name !== undefined) { party.vehicle.name = memParty.vehicle.name; } if (memParty.vehicle?.type !== undefined) { party.vehicle.type = memParty.vehicle.type; } if (memParty.vehicle?.plateId !== undefined) { party.vehicle.plateId = memParty.vehicle.plateId; } blameDoc.markModified(`parties.${docIdx}.vehicle`); if (memParty.insurance) { if (memParty.insurance.policyNumber !== undefined) { party.insurance.policyNumber = memParty.insurance.policyNumber; } if (memParty.insurance.company !== undefined) { party.insurance.company = memParty.insurance.company; } if (memParty.insurance.financialCeiling !== undefined) { party.insurance.financialCeiling = String(memParty.insurance.financialCeiling); } if (memParty.insurance.startDate !== undefined) { party.insurance.startDate = memParty.insurance.startDate; } if (memParty.insurance.endDate !== undefined) { party.insurance.endDate = memParty.insurance.endDate; } blameDoc.markModified(`parties.${docIdx}.insurance`); } } blameDoc.set("inquiries", inquiries); blameDoc.markModified("inquiries"); blameDoc.set("updatedAt", new Date()); await blameDoc.save(); } private async syncInquiriesToClaims( blameRequestId: string, inquiries: Record, ): Promise { const claims = await this.claimCaseDbService.find({ blameRequestId: new Types.ObjectId(blameRequestId), }); const inquiryPatch: Record = {}; if (inquiries.thirdParty) inquiryPatch["inquiries.thirdParty"] = inquiries.thirdParty; if (inquiries.person) inquiryPatch["inquiries.person"] = inquiries.person; if (!Object.keys(inquiryPatch).length) return 0; let updated = 0; for (const claim of claims) { const result = await this.claimCaseDbService.findByIdAndUpdate( String(claim._id), { $set: { ...inquiryPatch, updatedAt: new Date(), }, }, ); if (result) updated += 1; } return updated; } private recordPartyInquiry( inquiries: Record, key: "thirdParty" | "person", role: PartyRole, has: boolean, data: Record = {}, error?: any, ): void { const existingBlock = inquiries[key] || {}; const existingData = existingBlock.data && typeof existingBlock.data === "object" && !Array.isArray(existingBlock.data) ? { ...existingBlock.data } : {}; inquiries[key] = { has: has || Object.keys(existingData).length > 0, data: { ...existingData, [role]: data, }, ...(error ? { error: this.normalizeInquiryError(error) } : {}), updatedAt: new Date(), }; } private normalizeInquiryError(error: any): Record { return { message: error?.message || String(error), status: error?.status ?? error?.response?.status, data: error?.data ?? error?.response?.data, }; } private resolvePartyPlate(party: Record): PlateParts | null { const fromPlateId = this.parsePlateFromCompactString(party?.vehicle?.plateId); if (fromPlateId) return fromPlateId; const candidates = [ party?.vehicle?.inquiry?.mapped, party?.vehicle?.inquiry?.raw, party?.vehicle?.inquiry, party?.vehicle, ].filter(Boolean); for (const candidate of candidates) { const leftDigits = this.firstPresent(candidate.Plk1, candidate.platePartOne); const centerAlphabet = this.firstPresent( candidate.plateLetterid, candidate.plateLetterId, candidate.plateLetterTitle, ); const centerDigits = this.firstPresent(candidate.Plk3, candidate.platePartThree); const ir = this.firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber); if ( leftDigits !== undefined && centerAlphabet !== undefined && centerDigits !== undefined && ir !== undefined ) { const plateLetter = String(centerAlphabet).trim(); const parsed: PlateParts = { leftDigits: Number(leftDigits), centerAlphabet: plateLetter, centerDigits: Number(centerDigits), ir: Number(ir), }; if ( Number.isFinite(parsed.leftDigits) && Number.isFinite(parsed.centerDigits) && Number.isFinite(parsed.ir) && parsed.centerAlphabet ) { return parsed; } } } return null; } private parsePlateFromCompactString(plateId?: string): PlateParts | null { if (!plateId) return null; const parts = String(plateId).split("-"); if (parts.length !== 4) return null; const [irRaw, leftRaw, alphaRaw, centerRaw] = parts; const ir = Number(irRaw); const leftDigits = Number(leftRaw); const centerDigits = Number(centerRaw); const centerAlphabet = String(alphaRaw || "").trim(); if ( !Number.isFinite(ir) || !Number.isFinite(leftDigits) || !Number.isFinite(centerDigits) || !centerAlphabet ) { return null; } return { leftDigits, centerAlphabet, centerDigits, ir }; } private async waitForRateLimit(): Promise { const perMinute = Number(process.env.RATE_LIMIT_PER_MINUTE || 5); const minDelayMs = Math.ceil(60000 / Math.max(1, perMinute)); const elapsed = Date.now() - this.lastRequestAt; if (this.lastRequestAt > 0 && elapsed < minDelayMs) { await this.delay(minDelayMs - elapsed); } this.lastRequestAt = Date.now(); } private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } private cleanString(value: unknown): string { return value === undefined || value === null ? "" : String(value).trim(); } private firstPresent(...values: unknown[]): unknown { return values.find((value) => value !== undefined && value !== null && value !== ""); } }