forked from Yara724/api
lookup vehicle-kinds added
This commit is contained in:
135
src/lookups/entities/db-service/vehicle-kind.db.service.ts
Normal file
135
src/lookups/entities/db-service/vehicle-kind.db.service.ts
Normal 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[]>;
|
||||
}
|
||||
}
|
||||
63
src/lookups/entities/schema/vehicle-kind.schema.ts
Normal file
63
src/lookups/entities/schema/vehicle-kind.schema.ts
Normal 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 });
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
54
src/lookups/lookups.service.vehicle-list.spec.ts
Normal file
54
src/lookups/lookups.service.vehicle-list.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
49
src/lookups/vehicle-kind-search.spec.ts
Normal file
49
src/lookups/vehicle-kind-search.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
src/lookups/vehicle-kind-search.ts
Normal file
89
src/lookups/vehicle-kind-search.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user