forked from Yara724/api
the serial letter normalization added to the code to normalize the Heh , Ya , Kaf and etc to the correct character
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
8
src/utils/plate-normalizer/plate-normalizer.module.ts
Normal file
8
src/utils/plate-normalizer/plate-normalizer.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PlateNormalizerService } from "./plate-normalizer.service";
|
||||
|
||||
@Module({
|
||||
providers: [PlateNormalizerService],
|
||||
exports: [PlateNormalizerService],
|
||||
})
|
||||
export class PlateNormalizerModule {}
|
||||
79
src/utils/plate-normalizer/plate-normalizer.service.ts
Normal file
79
src/utils/plate-normalizer/plate-normalizer.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
131
src/utils/plate-normalizer/plate-normalizer.spec.ts
Normal file
131
src/utils/plate-normalizer/plate-normalizer.spec.ts
Normal 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 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<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user