Merge pull request 'main' (#221) from s.hajizadeh/yara724api:main into main

Reviewed-on: Yara724/api#221
This commit is contained in:
2026-07-27 10:39:57 +03:30
13 changed files with 337 additions and 11 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 },
]),

View File

@@ -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,
});

View File

@@ -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) ||

View File

@@ -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";
@@ -60,6 +61,7 @@ import { CallCenterBlameV6Controller } from "./call-center-blame-v6.controller";
SmsOrchestrationModule,
PublicIdModule,
HashModule,
PlateNormalizerModule,
WorkflowStepManagementModule,
PlatesModule,
MulterModule.register({

View File

@@ -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 },

View File

@@ -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({

View File

@@ -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),

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { PlateNormalizerService } from "./plate-normalizer.service";
@Module({
providers: [PlateNormalizerService],
exports: [PlateNormalizerService],
})
export class PlateNormalizerModule {}

View File

@@ -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<string, number>,
): 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;
}
}

View File

@@ -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 (U+FF13) → ASCII 3
expect(normalizePlateText("")).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<string, number> = {
"\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();
});
});
});