1
0
forked from Yara724/api

Compare commits

...

8 Commits

5 changed files with 935 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
export type FanavaranClientKey = "parsian" | "tejaratno";
export interface FanavaranAuthConfig {
appName: string;
secret: string;
username: string;
password: string;
corpId: string;
contractId: string;
location: string;
}
export interface FanavaranPayloadDefaults {
AccidentCityId: number;
AccidentReportTypeId: number;
AccidentVehicleUsedId: number;
ClaimExpertId: number;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;
}
export interface FanavaranClientProfile {
key: FanavaranClientKey;
auth: FanavaranAuthConfig;
defaults: FanavaranPayloadDefaults;
}
const FANAVARAN_CLIENT_PROFILES: Record<
FanavaranClientKey,
FanavaranClientProfile
> = {
tejaratno: {
key: "tejaratno",
auth: {
appName: "fanhab",
secret: "5Fa@N#A2B",
username: "fanhabUser",
password: "Fan#@2U$3er",
corpId: "3539",
contractId: "263",
location: "100",
},
defaults: {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 1589,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
},
},
parsian: {
key: "parsian",
auth: {
appName: "ParsianService",
secret: "P@r30@n$erv!ce",
username: "ParsianServiceUser",
password: "P@r30@n123",
corpId: "543",
contractId: "28",
location: "210050",
},
defaults: {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 154,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
},
},
};
/** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
export function resolveFanavaranClientKey(): FanavaranClientKey {
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
if (explicit === "parsian" || explicit === "tejaratno") {
return explicit;
}
// Optional deployment hint when FANAVARAN_CLIENT is not set.
if (String(process.env.CLIENT_ID ?? "") === "8") {
return "parsian";
}
return "tejaratno";
}
export function resolveFanavaranClientProfile(): FanavaranClientProfile {
return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()];
}
export function fanavaranManualSubmitPath(claimCaseId: string): string {
return `/v2/claim-request-management/fanavaran-submit/${claimCaseId}`;
}

View File

@@ -0,0 +1,118 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Min,
} from "class-validator";
import { PartyRole } from "../entities/schema/partyRole.enum";
export class ReinquiryInquiriesDto {
@ApiPropertyOptional({
description: "Blame public id (e.g. A00041). Use for single-case refresh.",
example: "A00041",
})
@IsOptional()
@IsString()
publicId?: string;
@ApiPropertyOptional({
description: "Blame case Mongo id. Alternative to publicId.",
})
@IsOptional()
@IsString()
blameRequestId?: string;
@ApiPropertyOptional({
description: "Bulk mode: max number of blame cases to process (0 = no limit).",
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
limit?: number;
@ApiPropertyOptional({
description: "Party roles to refresh. Defaults to FIRST and SECOND.",
enum: PartyRole,
isArray: true,
})
@IsOptional()
@IsArray()
@IsEnum(PartyRole, { each: true })
roles?: PartyRole[];
@ApiPropertyOptional({
description: "When true, runs inquiries but does not persist updates.",
default: false,
})
@IsOptional()
@IsBoolean()
dryRun?: boolean;
}
export class ReinquiryPartyInputDto {
plateId?: string;
plate?: {
ir: number;
leftDigits: number;
centerAlphabet: string;
centerDigits: number;
};
nationalCode?: string;
birthDate?: number | string;
}
export class ReinquiryPartyPreviewDto {
/** What would be written to inquiries.thirdParty.data[role] */
thirdParty?: Record<string, unknown>;
/** What would be written to inquiries.person.data[role] */
person?: Record<string, unknown>;
/** Summary of parties[].vehicle / parties[].insurance updates */
party?: {
vehicle?: { name?: string; type?: string; plateId?: string };
insurance?: {
policyNumber?: string;
company?: string;
financialCeiling?: string;
startDate?: string;
endDate?: string;
};
};
}
export class ReinquiryPartyResultDto {
role: PartyRole;
/** Values read from blameCase.parties[role] before calling external APIs */
input?: ReinquiryPartyInputDto;
/** Present when dryRun=true — shows what would be saved (not written to DB) */
preview?: ReinquiryPartyPreviewDto;
thirdParty?: { ok: boolean; message?: string };
person?: { ok: boolean; message?: string };
}
export class ReinquiryCaseResultDto {
blameRequestId: string;
publicId?: string;
blameUpdated: boolean;
claimsUpdated: number;
/** Full inquiries object that would be saved (dryRun only) */
inquiriesPreview?: {
thirdParty?: Record<string, unknown>;
person?: Record<string, unknown>;
};
parties: ReinquiryPartyResultDto[];
}
export class ReinquiryInquiriesResponseDto {
dryRun: boolean;
docsSeen: number;
blameDocsUpdated: number;
claimDocsUpdated: number;
results: ReinquiryCaseResultDto[];
}

View File

@@ -0,0 +1,56 @@
import {
Body,
Controller,
HttpException,
InternalServerErrorException,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import {
ReinquiryInquiriesDto,
ReinquiryInquiriesResponseDto,
} from "./dto/reinquiry-inquiries.dto";
import { InquiryRefreshService } from "./inquiry-refresh.service";
@ApiTags("blame-inquiry-refresh")
@Controller("v2/blame-inquiries")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.ADMIN, RoleEnum.FIELD_EXPERT)
export class InquiryRefreshController {
constructor(private readonly inquiryRefreshService: InquiryRefreshService) {}
@Post("reinquiry")
@ApiOperation({
summary: "Re-run third-party and person inquiries for blame/claim cases",
description:
"Refreshes inquiries for FIRST/SECOND parties on a blame case, updates parties + inquiries on blameCases, " +
"and syncs inquiries.thirdParty + inquiries.person to linked claimCases. " +
"Use publicId or blameRequestId for one case, or limit for bulk.",
})
@ApiBody({ type: ReinquiryInquiriesDto })
@ApiResponse({ status: 200, type: ReinquiryInquiriesResponseDto })
async reinquiry(
@Body() body: ReinquiryInquiriesDto,
): Promise<ReinquiryInquiriesResponseDto> {
try {
return await this.inquiryRefreshService.reinquiryInquiries(body);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to refresh inquiries",
);
}
}
}

View File

@@ -0,0 +1,659 @@
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<ReinquiryInquiriesResponseDto> {
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<string, unknown> = {
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<string, any>,
roles,
dryRun,
);
summary.results.push(caseResult);
if (caseResult.blameUpdated) summary.blameDocsUpdated += 1;
summary.claimDocsUpdated += caseResult.claimsUpdated;
}
return summary;
}
private async refreshBlameCase(
doc: Record<string, any>,
roles: PartyRole[],
dryRun: boolean,
): Promise<ReinquiryCaseResultDto> {
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<string, any>,
party: Record<string, any>,
role: PartyRole,
inquiries: Record<string, any>,
dryRun: boolean,
caseLabel: string,
): Promise<{
party: Record<string, any>;
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<string, any>,
raw: Record<string, any>,
mapped: Record<string, any>,
): Record<string, any> {
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<string, any>,
raw: Record<string, any>,
): 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<string, any>,
raw: Record<string, any>,
): 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<string, any>[],
inquiries: Record<string, any>,
): Promise<void> {
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<string, any>,
): Promise<number> {
const claims = await this.claimCaseDbService.find({
blameRequestId: new Types.ObjectId(blameRequestId),
});
const inquiryPatch: Record<string, unknown> = {};
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<string, any>,
key: "thirdParty" | "person",
role: PartyRole,
has: boolean,
data: Record<string, unknown> = {},
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<string, unknown> {
return {
message: error?.message || String(error),
status: error?.status ?? error?.response?.status,
data: error?.data ?? error?.response?.data,
};
}
private resolvePartyPlate(party: Record<string, any>): 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<void> {
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<void> {
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 !== "");
}
}

View File

@@ -42,6 +42,8 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller"; import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller";
import { RegistrarInitiatedController } from "./registrar-initiated.controller"; import { RegistrarInitiatedController } from "./registrar-initiated.controller";
import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller"; import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller";
import { InquiryRefreshController } from "./inquiry-refresh.controller";
import { InquiryRefreshService } from "./inquiry-refresh.service";
@Module({ @Module({
imports: [ imports: [
@@ -77,9 +79,11 @@ import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.control
ExpertInitiatedBlameMirrorController, ExpertInitiatedBlameMirrorController,
RegistrarInitiatedController, RegistrarInitiatedController,
RegistrarBlameMirrorController, RegistrarBlameMirrorController,
InquiryRefreshController,
], ],
providers: [ providers: [
RequestManagementService, RequestManagementService,
InquiryRefreshService,
BlameVideoDbService, BlameVideoDbService,
BlameVoiceDbService, BlameVoiceDbService,
UserSignDbService, UserSignDbService,