diff --git a/scripts/verify-plate-normalization.mjs b/scripts/verify-plate-normalization.mjs new file mode 100644 index 0000000..dce8581 --- /dev/null +++ b/scripts/verify-plate-normalization.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * Quick manual verification for plate normalization. + * + * Usage: node scripts/verify-plate-normalization.mjs + * + * Demonstrates that: + * 339ه‍77 (ه + U+200D) -> 339ه77 -> letter ه -> code 15 + * 339ي77 (Arabic ي) -> 339ی77 -> letter ی -> code 16 + */ + +// ---------- paste the pure function here so no build needed ---------- +function normalizePlateText(text) { + if (!text) return text; + let result = String(text); + result = result.normalize("NFKC"); + result = result.replace(/[\u200C\u200D\u200E\u200F\uFEFF]/g, ""); + result = result.replace(/\u064A/g, "\u06CC"); // Arabic ي → Persian ی + result = result.trim(); + return result; +} + +// ---------- the canonical plate-letter → code mapping ---------- +const PLATE_LETTER_MAP = { + الف: 1, ب: 2, پ: 3, ج: 4, د: 5, + س: 6, ص: 7, ط: 8, ع: 9, ق: 10, + ل: 11, م: 12, ن: 13, و: 14, ه: 15, + ی: 16, ک: 17, ژ: 18, ت: 19, ث: 20, + ز: 21, ش: 22, ف: 23, گ: 24, +}; + +function extractAndMap(plateString) { + const normalized = normalizePlateText(plateString); + + // Simple parser: digits | letter | digits (left+right digits, 1 letter) + const match = normalized.match(/^(\d+)([^\d]+)(\d+)$/); + if (!match) return { input: plateString, normalized, error: "no match" }; + + const [, leftDigits, letterRaw, rightDigits] = match; + const letter = normalizePlateText(letterRaw); + const code = PLATE_LETTER_MAP[letter]; + + return { + input: plateString, + normalized, + leftDigits, + letter, + rightDigits, + code, + ok: code !== undefined, + }; +} + +// ---------- test cases ---------- +const cases = [ + { desc: "ه + ZWJ (the original bug)", plate: "339ه\u200D77" }, + { desc: "Arabic ي → Persian ی", plate: "339ي77" }, + { desc: "ه + ZWNJ", plate: "339ه\u200C77" }, + { desc: "RTL mark around letter", plate: "339\u200Fه\u200E77" }, + { desc: "BOM + ZWJ combined", plate: "\uFEFF339ه\u200D77" }, + { desc: "clean letter (no junk)", plate: "339ه77" }, + { desc: "full plate: الف", plate: "11الف22" }, + { desc: "full plate: ی", plate: "55ی99" }, +]; + +console.log("Plate Normalization Verification\n"); +console.log("=".repeat(72)); + +let allPassed = true; +for (const { desc, plate } of cases) { + const r = extractAndMap(plate); + const status = r.ok ? "PASS" : "FAIL"; + if (!r.ok) allPassed = false; + + console.log(`\n[${status}] ${desc}`); + console.log(` Input: ${JSON.stringify(plate)}`); + console.log(` Normalized: ${JSON.stringify(r.normalized)}`); + if (r.letter) { + console.log(` Letter: ${r.letter} (U+${r.letter.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")})`); + } + console.log(` Code: ${r.code ?? "undefined"}`); +} + +console.log("\n" + "=".repeat(72)); +console.log(allPassed ? "\n All tests PASSED ✓" : "\n Some tests FAILED ✗"); +process.exit(allPassed ? 0 : 1); diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index a61076f..552c58f 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -57,6 +57,7 @@ import { JwtModule } from "@nestjs/jwt"; import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module"; import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; +import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; @Module({ imports: [ @@ -67,6 +68,7 @@ import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; }), FanavaranAuditModule, FanavaranLookupModule, + PlateNormalizerModule, PublicIdModule, UsersModule, RequestManagementModule, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 1509ae9..36e6e6d 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -156,6 +156,7 @@ import { resolvePartCaptureIndex, } from "src/helpers/outer-damage-parts"; import { normalizeMoneyAmountString } from "src/utils/unicode-digits"; +import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service"; import { CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, @@ -352,6 +353,7 @@ export class ClaimRequestManagementService { private readonly fanavaranLookupService: FanavaranLookupService, private readonly fileMakerDbService: FileMakerDbService, private readonly fieldExpertDbService: FieldExpertDbService, + private readonly plateNormalizer: PlateNormalizerService, ) {} private requiredDocumentKeysV2(isCarBody: boolean): string[] { @@ -503,7 +505,7 @@ export class ClaimRequestManagementService { const ir = Number(irRaw); const leftDigits = Number(leftRaw); const centerDigits = Number(centerRaw); - const centerAlphabet = String(alphaRaw || "").trim(); + const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || "")); if ( !Number.isFinite(ir) || !Number.isFinite(leftDigits) || @@ -530,11 +532,11 @@ export class ClaimRequestManagementService { Number.isFinite(Number(p.leftDigits)) && Number.isFinite(Number(p.centerDigits)) && Number.isFinite(Number(p.ir)) && - String(p.centerAlphabet || "").trim() + this.plateNormalizer.normalizePlateText(String(p.centerAlphabet || "")) ) { return { leftDigits: Number(p.leftDigits), - centerAlphabet: String(p.centerAlphabet).trim(), + centerAlphabet: this.plateNormalizer.normalizePlateText(String(p.centerAlphabet)), centerDigits: Number(p.centerDigits), ir: Number(p.ir), }; @@ -3602,9 +3604,10 @@ export class ClaimRequestManagementService { if (centerAlphabet == null) return null; const raw = String(centerAlphabet).trim(); if (!raw) return null; - const asNumber = Number(raw); + const normalized = this.plateNormalizer.normalizePlateText(raw); + const asNumber = Number(normalized); if (Number.isFinite(asNumber)) return asNumber; - return FANAVARAN_PLATE_LETTER_CODE[raw] ?? null; + return this.plateNormalizer.resolvePlateLetterCode(normalized, FANAVARAN_PLATE_LETTER_CODE); } private formatFanavaranPlateNo( diff --git a/src/plates/plates.module.ts b/src/plates/plates.module.ts index 4da9f75..53db18b 100644 --- a/src/plates/plates.module.ts +++ b/src/plates/plates.module.ts @@ -4,6 +4,7 @@ import { ClientModule } from "src/client/client.module"; import { PlateDbService } from "src/plates/entites/db-service/plate.db.service"; import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { UsersModule } from "src/users/users.module"; +import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; import { PlatesModel, PlatesSchema } from "./entites/schema/plate.schema"; import { PlatesService } from "./plates.service"; @@ -12,6 +13,7 @@ import { PlatesService } from "./plates.service"; UsersModule, SandHubModule, ClientModule, + PlateNormalizerModule, MongooseModule.forFeature([ { name: PlatesModel.name, schema: PlatesSchema }, ]), diff --git a/src/plates/plates.service.ts b/src/plates/plates.service.ts index e6ffd11..e41695a 100644 --- a/src/plates/plates.service.ts +++ b/src/plates/plates.service.ts @@ -7,20 +7,23 @@ import { import { Types } from "mongoose"; import { PlateDbService } from "src/plates/entites/db-service/plate.db.service"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; +import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service"; @Injectable() export class PlatesService { constructor( private readonly plateDbService: PlateDbService, + private readonly plateNormalizer: PlateNormalizerService, ) {} async addPlate(currentUser, body: AddPlateDto) { const { plate, nationalCodeOfInsurer } = body; + const normalizedAlphabet = this.plateNormalizer.normalizePlateText(String(plate.centerAlphabet || "")); const duplicate = await this.plateDbService.findOne({ userId: currentUser, leftDigits: plate.leftDigits, - centerAlphabet: plate.centerAlphabet, + centerAlphabet: normalizedAlphabet, centerDigits: plate.centerDigits, ir: plate.ir, nationalCodeOfInsurer, @@ -31,6 +34,7 @@ export class PlatesService { const newPlateData = await this.plateDbService.create({ ...plate, + centerAlphabet: normalizedAlphabet, userId: currentUser, nationalCodeOfInsurer: body.nationalCodeOfInsurer, }); diff --git a/src/request-management/inquiry-refresh.service.ts b/src/request-management/inquiry-refresh.service.ts index 5353c37..525949b 100644 --- a/src/request-management/inquiry-refresh.service.ts +++ b/src/request-management/inquiry-refresh.service.ts @@ -7,6 +7,7 @@ import { 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 { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.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"; @@ -33,6 +34,7 @@ export class InquiryRefreshService { private readonly blameRequestDbService: BlameRequestDbService, private readonly claimCaseDbService: ClaimCaseDbService, private readonly sandHubService: SandHubService, + private readonly plateNormalizer: PlateNormalizerService, ) {} async reinquiryInquiries( @@ -592,7 +594,7 @@ export class InquiryRefreshService { centerDigits !== undefined && ir !== undefined ) { - const plateLetter = String(centerAlphabet).trim(); + const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet)); const parsed: PlateParts = { leftDigits: Number(leftDigits), centerAlphabet: plateLetter, @@ -622,8 +624,7 @@ export class InquiryRefreshService { const ir = Number(irRaw); const leftDigits = Number(leftRaw); const centerDigits = Number(centerRaw); - const centerAlphabet = String(alphaRaw || "").trim(); - + const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || "")); if ( !Number.isFinite(ir) || !Number.isFinite(leftDigits) || diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 9da5a2d..c93a8a7 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -12,6 +12,7 @@ import { UsersModule } from "src/users/users.module"; import { CronModule } from "src/utils/cron/cron.module"; import { PublicIdModule } from "src/utils/public-id/public-id.module"; import { HashModule } from "src/utils/hash/hash.module"; +import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module"; import { AuthModule } from "src/auth/auth.module"; @@ -59,6 +60,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service"; SmsOrchestrationModule, PublicIdModule, HashModule, + PlateNormalizerModule, WorkflowStepManagementModule, PlatesModule, MulterModule.register({ diff --git a/src/sand-hub/sand-hub.module.ts b/src/sand-hub/sand-hub.module.ts index dbc48fc..3b2dc12 100644 --- a/src/sand-hub/sand-hub.module.ts +++ b/src/sand-hub/sand-hub.module.ts @@ -5,6 +5,7 @@ import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { MongooseModule } from "@nestjs/mongoose"; import { ClientModule } from "src/client/client.module"; import { SystemSettingsModule } from "src/system-settings/system-settings.module"; +import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema"; import { SandHubService } from "./sand-hub.service"; @@ -17,6 +18,7 @@ import { SandHubService } from "./sand-hub.service"; useFactory: createHttpModuleOptions, }), SystemSettingsModule, + PlateNormalizerModule, ClientModule, MongooseModule.forFeature([ { name: SandHubModel.name, schema: SandHubSchema }, diff --git a/src/sand-hub/sand-hub.service.spec.ts b/src/sand-hub/sand-hub.service.spec.ts index 3e09e01..1392658 100644 --- a/src/sand-hub/sand-hub.service.spec.ts +++ b/src/sand-hub/sand-hub.service.spec.ts @@ -9,6 +9,9 @@ describe("SandHubService inquiry mocks", () => { isInquiryLive: jest.fn(), getMockCompanyContext: jest.fn(), }; + const plateNormalizer = { + normalizePlateText: (text: string) => text, + }; let service: SandHubService; @@ -30,6 +33,7 @@ describe("SandHubService inquiry mocks", () => { httpService as any, sandHubDbService as any, externalInquirySettings as unknown as ExternalInquirySettingsService, + plateNormalizer as any, ); externalInquirySettings.isInquiryLive.mockResolvedValue(false); externalInquirySettings.getMockCompanyContext.mockResolvedValue({ diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index a6f8b5e..6925b65 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -12,6 +12,7 @@ import { 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 { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.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"; @@ -39,6 +40,7 @@ export class SandHubService { private readonly httpService: HttpService, private readonly sandHubDbService: SandHubDbService, private readonly externalInquirySettings: ExternalInquirySettingsService, + private readonly plateNormalizer: PlateNormalizerService, ) {} private clientRefFrom(options?: SandHubInquiryOptions): string | undefined { @@ -162,8 +164,8 @@ export class SandHubService { nationalCodeOfInsurer: nationalCode, plate: { leftDigits: Number(payload.part1 ?? payload.leftTwoDigits ?? 16), - centerAlphabet: String( - payload.part2 ?? payload.serialLetter ?? "12", + centerAlphabet: this.plateNormalizer.normalizePlateText( + String(payload.part2 ?? payload.serialLetter ?? "12"), ), centerDigits: Number(payload.part3 ?? payload.threeDigits ?? 498), ir: Number(payload.part4 ?? payload.rightTwoDigits ?? 60), diff --git a/src/utils/plate-normalizer/plate-normalizer.module.ts b/src/utils/plate-normalizer/plate-normalizer.module.ts new file mode 100644 index 0000000..f73e135 --- /dev/null +++ b/src/utils/plate-normalizer/plate-normalizer.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { PlateNormalizerService } from "./plate-normalizer.service"; + +@Module({ + providers: [PlateNormalizerService], + exports: [PlateNormalizerService], +}) +export class PlateNormalizerModule {} diff --git a/src/utils/plate-normalizer/plate-normalizer.service.ts b/src/utils/plate-normalizer/plate-normalizer.service.ts new file mode 100644 index 0000000..7f9ae1f --- /dev/null +++ b/src/utils/plate-normalizer/plate-normalizer.service.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from "@nestjs/common"; + +/** + * Characters stripped from plate text during normalization. + * These invisible Unicode code-points can sneak in via copy-paste + * or OCR and break exact-match lookups against the plate letter map. + */ +const INVISIBLE_CHARS = [ + "\u200C", // Zero-Width Non-Joiner + "\u200D", // Zero-Width Joiner + "\u200E", // Left-to-Right Mark + "\u200F", // Right-to-Left Mark + "\uFEFF", // BOM / Zero-Width No-Break Space +]; + +const INVISIBLE_RE = new RegExp(`[${INVISIBLE_CHARS.join("")}]`, "g"); + +/** + * Normalize a Persian license-plate character (or short text) so that + * visually-identical Unicode variants collapse to the canonical form + * used in the plate letter mapping. + * + * Steps (in order): + * 1. NFKC normalization — folds compatibility equivalents + * 2. Strip invisible Unicode characters (ZWJ, ZWNJ, LTR/RTL marks, BOM) + * 3. Convert Arabic ي (U+064A) → Persian ی (U+06CC) + * 4. Trim leading/trailing whitespace + */ +export function normalizePlateText(text: string): string { + if (!text) return text; + + let result = String(text); + + // 1. Unicode NFKC normalization + result = result.normalize("NFKC"); + + // 2. Remove invisible characters + result = result.replace(INVISIBLE_RE, ""); + + // 3. Arabic ي → Persian ی + result = result.replace(/\u064A/g, "\u06CC"); + + // 4. Trim whitespace + result = result.trim(); + + return result; +} + +@Injectable() +export class PlateNormalizerService { + private readonly logger = new Logger(PlateNormalizerService.name); + + /** @see module-level {@link normalizePlateText} */ + normalizePlateText(text: string): string { + return normalizePlateText(text); + } + + /** + * Normalize a plate letter and, if it doesn't resolve in the given + * mapping, log the raw Unicode code-points for debugging. + * + * @returns the numeric code from `mapping`, or `null` when the letter + * is unknown after normalization. + */ + resolvePlateLetterCode( + letter: string, + mapping: Record, + ): number | null { + const normalized = this.normalizePlateText(letter); + + const code = mapping[normalized]; + if (code !== undefined) return code; + + this.logger.debug( + `Unknown plate letter: ${normalized} [${Array.from(normalized).map((c) => `U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}]`, + ); + return null; + } +} diff --git a/src/utils/plate-normalizer/plate-normalizer.spec.ts b/src/utils/plate-normalizer/plate-normalizer.spec.ts new file mode 100644 index 0000000..6351c66 --- /dev/null +++ b/src/utils/plate-normalizer/plate-normalizer.spec.ts @@ -0,0 +1,131 @@ +import { PlateNormalizerService, normalizePlateText } from "./plate-normalizer.service"; + +describe("normalizePlateText (pure function)", () => { + it("returns unchanged text for a clean plate letter", () => { + expect(normalizePlateText("ه")).toBe("ه"); + expect(normalizePlateText("الف")).toBe("الف"); + }); + + it("strips Zero-Width Joiner (U+200D)", () => { + // ه + ZWJ — the exact scenario from the issue + const input = "ه\u200D"; + expect(normalizePlateText(input)).toBe("ه"); + }); + + it("strips Zero-Width Non-Joiner (U+200C)", () => { + expect(normalizePlateText("ه\u200C")).toBe("ه"); + }); + + it("strips Left-to-Right Mark (U+200E)", () => { + expect(normalizePlateText("ه\u200E")).toBe("ه"); + }); + + it("strips Right-to-Left Mark (U+200F)", () => { + expect(normalizePlateText("ه\u200F")).toBe("ه"); + }); + + it("strips BOM (U+FEFF)", () => { + expect(normalizePlateText("\uFEFFه")).toBe("ه"); + }); + + it("converts Arabic ي (U+064A) to Persian ی (U+06CC)", () => { + expect(normalizePlateText("\u064A")).toBe("\u06CC"); + }); + + it("does not alter already-Persian ی (U+06CC)", () => { + expect(normalizePlateText("\u06CC")).toBe("\u06CC"); + }); + + it("handles a full plate string: 339ه\u200D77 → 339ه77", () => { + expect(normalizePlateText("339ه\u200D77")).toBe("339ه77"); + }); + + it("handles multiple invisible chars interleaved", () => { + const input = "\u200F339\u200Cه\u200D\uFEFF77\u200E"; + expect(normalizePlateText(input)).toBe("339ه77"); + }); + + it("trims whitespace", () => { + expect(normalizePlateText(" ه ")).toBe("ه"); + }); + + it("applies NFKC normalization", () => { + // Full-width digit 3 (U+FF13) → ASCII 3 + expect(normalizePlateText("3")).toBe("3"); + }); + + it("returns empty string for empty input", () => { + expect(normalizePlateText("")).toBe(""); + }); +}); + +describe("PlateNormalizerService", () => { + let service: PlateNormalizerService; + + beforeEach(() => { + service = new PlateNormalizerService(); + }); + + describe("normalizePlateText", () => { + it("delegates to the pure function", () => { + expect(service.normalizePlateText("ه\u200D")).toBe("ه"); + }); + }); + + describe("resolvePlateLetterCode", () => { + const PLATE_LETTER_MAP: Record = { + "\u0627\u0644\u0641": 1, // الف + "\u0628": 2, + "\u067E": 3, + "\u062C": 4, + "\u062F": 5, + "\u0633": 6, + "\u0635": 7, + "\u0637": 8, + "\u0639": 9, + "\u0642": 10, + "\u0644": 11, + "\u0645": 12, + "\u0646": 13, + "\u0648": 14, + "\u0647": 15, // ه + "\u06CC": 16, // ی + "\u06A9": 17, + "\u0698": 18, + "\u062A": 19, + "\u062B": 20, + "\u0632": 21, + "\u0634": 22, + "\u0641": 23, + "\u06AF": 24, + }; + + it("returns 15 for clean ه", () => { + expect(service.resolvePlateLetterCode("ه", PLATE_LETTER_MAP)).toBe(15); + }); + + it("returns 15 for ه + ZWJ (the bug scenario)", () => { + expect(service.resolvePlateLetterCode("ه\u200D", PLATE_LETTER_MAP)).toBe( + 15, + ); + }); + + it("returns 16 for Arabic ي (mapped to Persian ی)", () => { + expect( + service.resolvePlateLetterCode("\u064A", PLATE_LETTER_MAP), + ).toBe(16); + }); + + it("returns null for an unknown letter and logs it", () => { + const spy = jest.spyOn( + (service as any).logger, + "debug", + ); + expect(service.resolvePlateLetterCode("؟", PLATE_LETTER_MAP)).toBeNull(); + expect(spy).toHaveBeenCalledWith( + expect.stringContaining("Unknown plate letter"), + ); + spy.mockRestore(); + }); + }); +});