diff --git a/src/lookups/entities/db-service/vehicle-kind.db.service.ts b/src/lookups/entities/db-service/vehicle-kind.db.service.ts new file mode 100644 index 0000000..877b839 --- /dev/null +++ b/src/lookups/entities/db-service/vehicle-kind.db.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { FilterQuery, Model } from "mongoose"; +import { + VehicleKindModel, + type VehicleKindDocument, +} from "../schema/vehicle-kind.schema"; +import { normalizeVehicleKindCaptionSearch } from "../../vehicle-kind-search"; + +export type VehicleKindRow = { + Id: number; + Caption: string; + IsActive: number; + CylinderCount?: number | null; + PassengerCount?: number | null; + Tonnage?: number | null; + VehicleCategoryCaption?: string | null; + VehicleGroupId?: number | null; + VehicleSystemCaption?: string | null; + VehicleSystemId?: number | null; +}; + +const PUBLIC_FIELDS = { + _id: 0, + Id: 1, + Caption: 1, + IsActive: 1, + CylinderCount: 1, + PassengerCount: 1, + Tonnage: 1, + VehicleCategoryCaption: 1, + VehicleGroupId: 1, + VehicleSystemCaption: 1, + VehicleSystemId: 1, +} as const; + +@Injectable() +export class VehicleKindDbService { + constructor( + @InjectModel(VehicleKindModel.name) + private readonly model: Model, + ) {} + + async countDocuments( + filter: FilterQuery = {}, + ): Promise { + return this.model.countDocuments(filter).exec(); + } + + async getLatestSyncedAt(): Promise { + const row = await this.model + .findOne({}, { syncedAt: 1, _id: 0 }) + .sort({ syncedAt: -1 }) + .lean() + .exec(); + return row?.syncedAt ? new Date(row.syncedAt) : null; + } + + /** + * Replace the whole collection with a Fanavaran snapshot (atomic-ish via bulk upsert + delete stale). + */ + async replaceAllFromFanavaran(rows: VehicleKindRow[]): Promise { + const syncedAt = new Date(); + if (!rows.length) { + await this.model.deleteMany({}).exec(); + return 0; + } + + const ops = rows.map((row) => ({ + updateOne: { + filter: { Id: row.Id }, + update: { + $set: { + Id: row.Id, + Caption: row.Caption, + captionSearch: normalizeVehicleKindCaptionSearch(row.Caption), + IsActive: row.IsActive, + CylinderCount: row.CylinderCount ?? null, + PassengerCount: row.PassengerCount ?? null, + Tonnage: row.Tonnage ?? null, + VehicleCategoryCaption: row.VehicleCategoryCaption ?? null, + VehicleGroupId: row.VehicleGroupId ?? null, + VehicleSystemCaption: row.VehicleSystemCaption ?? null, + VehicleSystemId: row.VehicleSystemId ?? null, + syncedAt, + }, + }, + upsert: true, + }, + })); + + const BATCH = 500; + for (let i = 0; i < ops.length; i += BATCH) { + await this.model.bulkWrite(ops.slice(i, i + BATCH), { ordered: false }); + } + + const ids = rows.map((r) => r.Id); + await this.model.deleteMany({ Id: { $nin: ids } }).exec(); + return rows.length; + } + + async findAllPublic(): Promise { + return this.model + .find({}, PUBLIC_FIELDS) + .sort({ Id: 1 }) + .lean() + .exec() as Promise; + } + + /** + * Active rows whose Caption contains `q` (normalized). + * Sorted by Caption length then Caption for nicer typeahead ranking. + */ + async searchActiveByCaption( + q: string, + limit: number, + ): Promise { + const needle = normalizeVehicleKindCaptionSearch(q); + if (!needle) return []; + + const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return this.model + .find( + { + IsActive: 1, + captionSearch: { $regex: escaped }, + }, + PUBLIC_FIELDS, + ) + .sort({ Caption: 1 }) + .limit(limit) + .lean() + .exec() as Promise; + } +} diff --git a/src/lookups/entities/schema/vehicle-kind.schema.ts b/src/lookups/entities/schema/vehicle-kind.schema.ts new file mode 100644 index 0000000..3a9fe7b --- /dev/null +++ b/src/lookups/entities/schema/vehicle-kind.schema.ts @@ -0,0 +1,63 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Document } from "mongoose"; + +export type VehicleKindDocument = VehicleKindModel & Document; + +/** + * One Fanavaran vehicle-kind row per document. + * Collection: `vehicle-kinds` + */ +@Schema({ versionKey: false, collection: "vehicle-kinds", timestamps: true }) +export class VehicleKindModel { + @Prop({ required: true, unique: true }) + Id: number; + + @Prop({ required: true }) + Caption: string; + + /** + * Normalized Caption for case-/yeh-insensitive substring search. + * Indexed for active dropdown queries. + */ + @Prop({ required: true, index: true }) + captionSearch: string; + + @Prop({ required: true, index: true }) + IsActive: number; + + @Prop() + CylinderCount?: number | null; + + @Prop() + PassengerCount?: number | null; + + @Prop() + Tonnage?: number | null; + + @Prop() + VehicleCategoryCaption?: string | null; + + @Prop() + VehicleGroupId?: number | null; + + @Prop() + VehicleSystemCaption?: string | null; + + @Prop() + VehicleSystemId?: number | null; + + /** When this row was last written from Fanavaran / cache sync. */ + @Prop({ required: true, index: true }) + syncedAt: Date; + + @Prop() + createdAt?: Date; + + @Prop() + updatedAt?: Date; +} + +export const VehicleKindSchema = SchemaFactory.createForClass(VehicleKindModel); + +VehicleKindSchema.index({ IsActive: 1, captionSearch: 1 }); +VehicleKindSchema.index({ IsActive: 1, Caption: 1 }); diff --git a/src/lookups/lookups.controller.ts b/src/lookups/lookups.controller.ts index 32448ef..aca554d 100644 --- a/src/lookups/lookups.controller.ts +++ b/src/lookups/lookups.controller.ts @@ -283,7 +283,7 @@ export class LookupsController { @ApiOperation({ summary: "Fanavaran GEN.12 vehicle kind lookup", description: - "Returns values for damage case payload field VehicleKindId from car/base-info/vehicle-kinds.", + "Returns all vehicle kinds and upserts them into the Mongo `vehicle-kinds` collection (refreshed at least weekly).", }) @ApiOkResponse({ description: "Returns Fanavaran vehicle kinds lookup data", @@ -293,6 +293,58 @@ export class LookupsController { return await this.lookupsService.getVehicleKinds(); } + @Get("vehicle-list") + @ApiOperation({ + summary: "Search active vehicle kinds for UI typeahead", + description: + "Searches Mongo `vehicle-kinds` by Caption (IsActive=1 only). " + + "Pass the exact current input as `q` on every keystroke. " + + "The response always echoes `q` — ignore a response when `response.q !== currentInput` " + + "so out-of-order network replies cannot paint a one-letter-behind list.", + }) + @ApiQuery({ + name: "q", + required: false, + description: "Caption substring (Persian/English). Echoed back in the response.", + example: "ول", + }) + @ApiQuery({ + name: "limit", + required: false, + description: "Max rows (1–100, default 50)", + example: 50, + }) + @ApiOkResponse({ + description: "Matched active vehicle kinds with echoed query", + schema: { + type: "object", + properties: { + q: { type: "string", example: "ول" }, + count: { type: "number", example: 12 }, + items: { + type: "array", + items: { + type: "object", + properties: { + Id: { type: "number" }, + Caption: { type: "string" }, + IsActive: { type: "number", example: 1 }, + }, + }, + }, + }, + }, + }) + async getVehicleList( + @Query("q") q?: string, + @Query("limit") limit?: string, + ) { + return await this.lookupsService.searchVehicleKindsList({ + q, + limit: limit != null && limit !== "" ? Number(limit) : undefined, + }); + } + @Get("person-role") @ApiOperation({ summary: "Fanavaran person role lookup", diff --git a/src/lookups/lookups.module.ts b/src/lookups/lookups.module.ts index 0719158..b188063 100644 --- a/src/lookups/lookups.module.ts +++ b/src/lookups/lookups.module.ts @@ -2,7 +2,12 @@ import { Module } from "@nestjs/common"; import { MongooseModule } from "@nestjs/mongoose"; import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { LookupDbService } from "./entities/db-service/lookup.db.service"; +import { VehicleKindDbService } from "./entities/db-service/vehicle-kind.db.service"; import { LookupModel, LookupSchema } from "./entities/schema/lookup.schema"; +import { + VehicleKindModel, + VehicleKindSchema, +} from "./entities/schema/vehicle-kind.schema"; import { LookupsController } from "./lookups.controller"; import { LookupsService } from "./lookups.service"; @@ -11,11 +16,11 @@ import { LookupsService } from "./lookups.service"; FanavaranLookupModule, MongooseModule.forFeature([ { name: LookupModel.name, schema: LookupSchema }, + { name: VehicleKindModel.name, schema: VehicleKindSchema }, ]), ], controllers: [LookupsController], - providers: [LookupsService, LookupDbService], - exports: [LookupsService, LookupDbService], + providers: [LookupsService, LookupDbService, VehicleKindDbService], + exports: [LookupsService, LookupDbService, VehicleKindDbService], }) export class LookupsModule {} - diff --git a/src/lookups/lookups.service.last-car-policy.spec.ts b/src/lookups/lookups.service.last-car-policy.spec.ts index 76b58e6..227f2d2 100644 --- a/src/lookups/lookups.service.last-car-policy.spec.ts +++ b/src/lookups/lookups.service.last-car-policy.spec.ts @@ -13,10 +13,13 @@ describe("LookupsService.findLastProcessedCarPolicy", () => { }); function createService(lookup: Record) { - return new LookupsService( - {} as any, - lookup as any, - ); + return new LookupsService({} as any, lookup as any, { + countDocuments: jest.fn().mockResolvedValue(1), + getLatestSyncedAt: jest.fn().mockResolvedValue(new Date()), + findAllPublic: jest.fn().mockResolvedValue([]), + searchActiveByCaption: jest.fn().mockResolvedValue([]), + replaceAllFromFanavaran: jest.fn().mockResolvedValue(0), + } as any); } it("returns the last matching third-party policy and its vehicle", async () => { diff --git a/src/lookups/lookups.service.ts b/src/lookups/lookups.service.ts index 2847fa3..29a49d1 100644 --- a/src/lookups/lookups.service.ts +++ b/src/lookups/lookups.service.ts @@ -3,6 +3,8 @@ import { Injectable, Logger, NotFoundException, + OnModuleDestroy, + OnModuleInit, } from "@nestjs/common"; import { resolveFanavaranClientKey, @@ -20,6 +22,7 @@ import { selectFanavaranDriverId, } from "src/claim-request-management/fanavaran-driver-inquiry"; import { LookupDbService } from "./entities/db-service/lookup.db.service"; +import { VehicleKindDbService } from "./entities/db-service/vehicle-kind.db.service"; import { asObjectRecord, filterPoliciesByLine, @@ -36,6 +39,11 @@ import { type FanavaranCarPolicyProduct, type HydratedFanavaranCarPolicy, } from "./fanavaran-last-car-policy"; +import { + asVehicleKindRows, + isVehicleKindsSyncStale, + VEHICLE_KINDS_REFRESH_MS, +} from "./vehicle-kind-search"; import { gregorianDateInIran } from "src/helpers/iran-datetime"; type TejaratAccidentReasonRow = { @@ -50,14 +58,41 @@ type TejaratAccidentLabelRow = { }; @Injectable() -export class LookupsService { +export class LookupsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(LookupsService.name); + private vehicleKindsSyncPromise: Promise | null = null; + private vehicleKindsRefreshTimer: ReturnType | null = + null; constructor( private readonly lookupDbService: LookupDbService, private readonly fanavaranLookupService: FanavaranLookupService, + private readonly vehicleKindDbService: VehicleKindDbService, ) {} + onModuleInit() { + this.vehicleKindsRefreshTimer = setInterval(() => { + void this.ensureVehicleKindsSynced(true).catch((error) => { + this.logger.warn( + `Weekly vehicle-kinds refresh failed: ${ + error instanceof Error ? error.message : error + }`, + ); + }); + }, VEHICLE_KINDS_REFRESH_MS); + // Best-effort warm sync shortly after boot (non-blocking). + setTimeout(() => { + void this.ensureVehicleKindsSynced(false).catch(() => undefined); + }, 15_000); + } + + onModuleDestroy() { + if (this.vehicleKindsRefreshTimer) { + clearInterval(this.vehicleKindsRefreshTimer); + this.vehicleKindsRefreshTimer = null; + } + } + private activeClientKey() { return resolveFanavaranClientKey(); } @@ -182,9 +217,122 @@ export class LookupsService { } async getVehicleKinds(): Promise { + await this.ensureVehicleKindsSynced(false); + const fromDb = await this.vehicleKindDbService.findAllPublic(); + if (fromDb.length > 0) return fromDb; + // Fallback if sync somehow left the collection empty. return await this.getClientRemoteLookup("vehicle-kinds"); } + /** + * Typeahead for frontend dropdowns. + * Always echoes `q` so the client can ignore stale responses (race when + * keystrokes outrun in-flight requests). + */ + async searchVehicleKindsList(input: { + q?: string | null; + limit?: number | null; + }): Promise<{ + q: string; + count: number; + items: Array>; + }> { + const q = String(input.q ?? "").trim(); + const limitRaw = Number(input.limit ?? 50); + const limit = Number.isFinite(limitRaw) + ? Math.min(Math.max(Math.trunc(limitRaw), 1), 100) + : 50; + + await this.ensureVehicleKindsSynced(false); + + if (!q) { + return { q: "", count: 0, items: [] }; + } + + const items = await this.vehicleKindDbService.searchActiveByCaption( + q, + limit, + ); + return { + q, + count: items.length, + items: items as unknown as Array>, + }; + } + + /** + * Ensure `vehicle-kinds` Mongo collection is populated and not older than 1 week. + * Concurrent callers share one in-flight sync. + */ + async ensureVehicleKindsSynced(force: boolean): Promise { + if (this.vehicleKindsSyncPromise) { + return this.vehicleKindsSyncPromise; + } + + const count = await this.vehicleKindDbService.countDocuments(); + const latest = await this.vehicleKindDbService.getLatestSyncedAt(); + const stale = isVehicleKindsSyncStale(latest); + if (!force && count > 0 && !stale) { + return; + } + + this.vehicleKindsSyncPromise = this.syncVehicleKindsCollection() + .catch((error) => { + this.logger.warn( + `vehicle-kinds sync failed: ${ + error instanceof Error ? error.message : error + }`, + ); + // If we already have rows, keep serving them. + if (count === 0) throw error; + }) + .finally(() => { + this.vehicleKindsSyncPromise = null; + }); + + return this.vehicleKindsSyncPromise; + } + + private async syncVehicleKindsCollection(): Promise { + const clientKey = this.activeClientKey(); + const definition = this.findRemoteLookup("vehicle-kinds"); + + let raw: unknown; + try { + raw = await this.fanavaranLookupService.fetchFromFanavaran( + clientKey, + definition.url, + ); + await this.fanavaranLookupService.writeCacheFile( + clientKey, + definition.cacheFile, + raw, + ); + } catch (error) { + this.logger.warn( + `Live Fanavaran vehicle-kinds fetch failed; falling back to cache/file: ${ + error instanceof Error ? error.message : error + }`, + ); + raw = await this.fanavaranLookupService.getRemoteLookup( + clientKey, + definition.url, + definition.cacheFile, + ); + } + + const rows = asVehicleKindRows(raw); + if (!rows.length) { + throw new NotFoundException("Fanavaran vehicle-kinds returned no rows"); + } + + const written = + await this.vehicleKindDbService.replaceAllFromFanavaran(rows); + this.logger.log( + `Synced vehicle-kinds collection: ${written} documents (client=${clientKey})`, + ); + } + async getPersonRole(): Promise { return await this.getClientRemoteLookup("person-role"); } diff --git a/src/lookups/lookups.service.vehicle-list.spec.ts b/src/lookups/lookups.service.vehicle-list.spec.ts new file mode 100644 index 0000000..3cf36f7 --- /dev/null +++ b/src/lookups/lookups.service.vehicle-list.spec.ts @@ -0,0 +1,54 @@ +import { LookupsService } from "./lookups.service"; + +describe("LookupsService.searchVehicleKindsList", () => { + it("echoes q and returns active Caption matches for typeahead", async () => { + const searchActiveByCaption = jest.fn().mockResolvedValue([ + { + Id: 6645, + Caption: "کاميون کشنده ولوو FH12 4*2", + IsActive: 1, + }, + ]); + + const service = new LookupsService({} as any, {} as any, { + countDocuments: jest.fn().mockResolvedValue(10), + getLatestSyncedAt: jest.fn().mockResolvedValue(new Date()), + findAllPublic: jest.fn().mockResolvedValue([]), + searchActiveByCaption, + replaceAllFromFanavaran: jest.fn(), + } as any); + + const result = await service.searchVehicleKindsList({ q: "ول", limit: 20 }); + + expect(result).toEqual({ + q: "ول", + count: 1, + items: [ + { + Id: 6645, + Caption: "کاميون کشنده ولوو FH12 4*2", + IsActive: 1, + }, + ], + }); + expect(searchActiveByCaption).toHaveBeenCalledWith("ول", 20); + }); + + it("returns empty items for blank q without searching", async () => { + const searchActiveByCaption = jest.fn(); + const service = new LookupsService({} as any, {} as any, { + countDocuments: jest.fn().mockResolvedValue(10), + getLatestSyncedAt: jest.fn().mockResolvedValue(new Date()), + findAllPublic: jest.fn().mockResolvedValue([]), + searchActiveByCaption, + replaceAllFromFanavaran: jest.fn(), + } as any); + + await expect(service.searchVehicleKindsList({ q: " " })).resolves.toEqual({ + q: "", + count: 0, + items: [], + }); + expect(searchActiveByCaption).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lookups/lookups.service.vehicle.spec.ts b/src/lookups/lookups.service.vehicle.spec.ts index 1d7c3b6..590d803 100644 --- a/src/lookups/lookups.service.vehicle.spec.ts +++ b/src/lookups/lookups.service.vehicle.spec.ts @@ -13,7 +13,13 @@ describe("LookupsService.vehicleById", () => { }); function createService(lookup: Record) { - return new LookupsService({} as any, lookup as any); + return new LookupsService({} as any, lookup as any, { + countDocuments: jest.fn().mockResolvedValue(1), + getLatestSyncedAt: jest.fn().mockResolvedValue(new Date()), + findAllPublic: jest.fn().mockResolvedValue([]), + searchActiveByCaption: jest.fn().mockResolvedValue([]), + replaceAllFromFanavaran: jest.fn().mockResolvedValue(0), + } as any); } it("returns the vehicle with kind, used, and plaque lookups", async () => { diff --git a/src/lookups/vehicle-kind-search.spec.ts b/src/lookups/vehicle-kind-search.spec.ts new file mode 100644 index 0000000..9edcf95 --- /dev/null +++ b/src/lookups/vehicle-kind-search.spec.ts @@ -0,0 +1,49 @@ +import { + asVehicleKindRows, + isVehicleKindsSyncStale, + normalizeVehicleKindCaptionSearch, + VEHICLE_KINDS_REFRESH_MS, +} from "./vehicle-kind-search"; + +describe("vehicle-kind-search", () => { + it("normalizes Arabic yeh/keheh so كاميون matches کامیون", () => { + expect(normalizeVehicleKindCaptionSearch("كاميون")).toBe( + normalizeVehicleKindCaptionSearch("کامیون"), + ); + expect(normalizeVehicleKindCaptionSearch("ولوو")).toContain("ولوو"); + }); + + it("treats missing or old syncedAt as stale after one week", () => { + expect(isVehicleKindsSyncStale(null)).toBe(true); + const now = new Date("2026-09-28T12:00:00Z"); + expect( + isVehicleKindsSyncStale( + new Date("2026-09-20T12:00:00Z"), + now, + VEHICLE_KINDS_REFRESH_MS, + ), + ).toBe(true); + expect( + isVehicleKindsSyncStale( + new Date("2026-09-25T12:00:00Z"), + now, + VEHICLE_KINDS_REFRESH_MS, + ), + ).toBe(false); + }); + + it("maps Fanavaran rows and drops invalid Ids", () => { + const rows = asVehicleKindRows([ + { Id: 6645, Caption: "کاميون کشنده ولوو FH12 4*2", IsActive: 1 }, + { Id: "bad", Caption: "x", IsActive: 1 }, + { Id: 1, Caption: "سایر", IsActive: 0 }, + ]); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + Id: 6645, + Caption: "کاميون کشنده ولوو FH12 4*2", + IsActive: 1, + }); + expect(rows[1].IsActive).toBe(0); + }); +}); diff --git a/src/lookups/vehicle-kind-search.ts b/src/lookups/vehicle-kind-search.ts new file mode 100644 index 0000000..f771a1f --- /dev/null +++ b/src/lookups/vehicle-kind-search.ts @@ -0,0 +1,89 @@ +import { toEnglishDigits } from "src/lookups/fanavaran-last-car-policy"; + +/** + * Normalize Fanavaran Captions / typeahead queries so Arabic/Persian yeh-keheh + * and digits match consistently. + */ +export function normalizeVehicleKindCaptionSearch(value: unknown): string { + return toEnglishDigits(value) + .toLowerCase() + .replace(/[يى]/g, "ی") + .replace(/[ك]/g, "ک") + .replace(/[\u200c\u200f\u200e]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +export const VEHICLE_KINDS_REFRESH_MS = 7 * 24 * 60 * 60 * 1000; + +export function isVehicleKindsSyncStale( + latestSyncedAt: Date | null | undefined, + now: Date = new Date(), + maxAgeMs: number = VEHICLE_KINDS_REFRESH_MS, +): boolean { + if (!latestSyncedAt) return true; + return now.getTime() - latestSyncedAt.getTime() >= maxAgeMs; +} + +export type VehicleKindFanavaranRow = { + Id?: unknown; + Caption?: unknown; + IsActive?: unknown; + CylinderCount?: unknown; + PassengerCount?: unknown; + Tonnage?: unknown; + VehicleCategoryCaption?: unknown; + VehicleGroupId?: unknown; + VehicleSystemCaption?: unknown; + VehicleSystemId?: unknown; +}; + +export function asVehicleKindRows( + value: unknown, +): Array<{ + Id: number; + Caption: string; + IsActive: number; + CylinderCount: number | null; + PassengerCount: number | null; + Tonnage: number | null; + VehicleCategoryCaption: string | null; + VehicleGroupId: number | null; + VehicleSystemCaption: string | null; + VehicleSystemId: number | null; +}> { + if (!Array.isArray(value)) return []; + const out: ReturnType = []; + for (const item of value) { + if (!item || typeof item !== "object") continue; + const row = item as VehicleKindFanavaranRow; + const id = Number(row.Id); + if (!Number.isFinite(id) || id <= 0) continue; + const caption = typeof row.Caption === "string" ? row.Caption : ""; + out.push({ + Id: id, + Caption: caption, + IsActive: Number(row.IsActive) === 1 ? 1 : 0, + CylinderCount: + row.CylinderCount == null ? null : Number(row.CylinderCount) || null, + PassengerCount: + row.PassengerCount == null ? null : Number(row.PassengerCount) || null, + Tonnage: row.Tonnage == null ? null : Number(row.Tonnage) || null, + VehicleCategoryCaption: + typeof row.VehicleCategoryCaption === "string" + ? row.VehicleCategoryCaption + : null, + VehicleGroupId: + row.VehicleGroupId == null ? null : Number(row.VehicleGroupId) || null, + VehicleSystemCaption: + typeof row.VehicleSystemCaption === "string" + ? row.VehicleSystemCaption + : null, + VehicleSystemId: + row.VehicleSystemId == null + ? null + : Number(row.VehicleSystemId) || null, + }); + } + return out; +}