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

Reviewed-on: Yara724/api#345
This commit is contained in:
2026-09-28 17:10:30 +03:30
10 changed files with 614 additions and 10 deletions

View File

@@ -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<VehicleKindDocument>,
) {}
async countDocuments(
filter: FilterQuery<VehicleKindModel> = {},
): Promise<number> {
return this.model.countDocuments(filter).exec();
}
async getLatestSyncedAt(): Promise<Date | null> {
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<number> {
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<VehicleKindRow[]> {
return this.model
.find({}, PUBLIC_FIELDS)
.sort({ Id: 1 })
.lean()
.exec() as Promise<VehicleKindRow[]>;
}
/**
* Active rows whose Caption contains `q` (normalized).
* Sorted by Caption length then Caption for nicer typeahead ranking.
*/
async searchActiveByCaption(
q: string,
limit: number,
): Promise<VehicleKindRow[]> {
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<VehicleKindRow[]>;
}
}

View File

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

View File

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

View File

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

View File

@@ -13,10 +13,13 @@ describe("LookupsService.findLastProcessedCarPolicy", () => {
});
function createService(lookup: Record<string, jest.Mock>) {
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 () => {

View File

@@ -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<void> | null = null;
private vehicleKindsRefreshTimer: ReturnType<typeof setInterval> | 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<any> {
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<Record<string, unknown>>;
}> {
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<Record<string, unknown>>,
};
}
/**
* 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<void> {
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<void> {
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<any> {
return await this.getClientRemoteLookup("person-role");
}

View File

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

View File

@@ -13,7 +13,13 @@ describe("LookupsService.vehicleById", () => {
});
function createService(lookup: Record<string, jest.Mock>) {
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 () => {

View File

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

View File

@@ -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<typeof asVehicleKindRows> = [];
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;
}