forked from Yara724/api
Merge pull request 'main' (#345) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#345
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({
|
@ApiOperation({
|
||||||
summary: "Fanavaran GEN.12 vehicle kind lookup",
|
summary: "Fanavaran GEN.12 vehicle kind lookup",
|
||||||
description:
|
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({
|
@ApiOkResponse({
|
||||||
description: "Returns Fanavaran vehicle kinds lookup data",
|
description: "Returns Fanavaran vehicle kinds lookup data",
|
||||||
@@ -293,6 +293,58 @@ export class LookupsController {
|
|||||||
return await this.lookupsService.getVehicleKinds();
|
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")
|
@Get("person-role")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Fanavaran person role lookup",
|
summary: "Fanavaran person role lookup",
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { Module } from "@nestjs/common";
|
|||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||||
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
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 { LookupModel, LookupSchema } from "./entities/schema/lookup.schema";
|
||||||
|
import {
|
||||||
|
VehicleKindModel,
|
||||||
|
VehicleKindSchema,
|
||||||
|
} from "./entities/schema/vehicle-kind.schema";
|
||||||
import { LookupsController } from "./lookups.controller";
|
import { LookupsController } from "./lookups.controller";
|
||||||
import { LookupsService } from "./lookups.service";
|
import { LookupsService } from "./lookups.service";
|
||||||
|
|
||||||
@@ -11,11 +16,11 @@ import { LookupsService } from "./lookups.service";
|
|||||||
FanavaranLookupModule,
|
FanavaranLookupModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: LookupModel.name, schema: LookupSchema },
|
{ name: LookupModel.name, schema: LookupSchema },
|
||||||
|
{ name: VehicleKindModel.name, schema: VehicleKindSchema },
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [LookupsController],
|
controllers: [LookupsController],
|
||||||
providers: [LookupsService, LookupDbService],
|
providers: [LookupsService, LookupDbService, VehicleKindDbService],
|
||||||
exports: [LookupsService, LookupDbService],
|
exports: [LookupsService, LookupDbService, VehicleKindDbService],
|
||||||
})
|
})
|
||||||
export class LookupsModule {}
|
export class LookupsModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ describe("LookupsService.findLastProcessedCarPolicy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function createService(lookup: Record<string, jest.Mock>) {
|
function createService(lookup: Record<string, jest.Mock>) {
|
||||||
return new LookupsService(
|
return new LookupsService({} as any, lookup as any, {
|
||||||
{} as any,
|
countDocuments: jest.fn().mockResolvedValue(1),
|
||||||
lookup as any,
|
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 () => {
|
it("returns the last matching third-party policy and its vehicle", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
resolveFanavaranClientKey,
|
resolveFanavaranClientKey,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
selectFanavaranDriverId,
|
selectFanavaranDriverId,
|
||||||
} from "src/claim-request-management/fanavaran-driver-inquiry";
|
} from "src/claim-request-management/fanavaran-driver-inquiry";
|
||||||
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
||||||
|
import { VehicleKindDbService } from "./entities/db-service/vehicle-kind.db.service";
|
||||||
import {
|
import {
|
||||||
asObjectRecord,
|
asObjectRecord,
|
||||||
filterPoliciesByLine,
|
filterPoliciesByLine,
|
||||||
@@ -36,6 +39,11 @@ import {
|
|||||||
type FanavaranCarPolicyProduct,
|
type FanavaranCarPolicyProduct,
|
||||||
type HydratedFanavaranCarPolicy,
|
type HydratedFanavaranCarPolicy,
|
||||||
} from "./fanavaran-last-car-policy";
|
} from "./fanavaran-last-car-policy";
|
||||||
|
import {
|
||||||
|
asVehicleKindRows,
|
||||||
|
isVehicleKindsSyncStale,
|
||||||
|
VEHICLE_KINDS_REFRESH_MS,
|
||||||
|
} from "./vehicle-kind-search";
|
||||||
import { gregorianDateInIran } from "src/helpers/iran-datetime";
|
import { gregorianDateInIran } from "src/helpers/iran-datetime";
|
||||||
|
|
||||||
type TejaratAccidentReasonRow = {
|
type TejaratAccidentReasonRow = {
|
||||||
@@ -50,14 +58,41 @@ type TejaratAccidentLabelRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LookupsService {
|
export class LookupsService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(LookupsService.name);
|
private readonly logger = new Logger(LookupsService.name);
|
||||||
|
private vehicleKindsSyncPromise: Promise<void> | null = null;
|
||||||
|
private vehicleKindsRefreshTimer: ReturnType<typeof setInterval> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly lookupDbService: LookupDbService,
|
private readonly lookupDbService: LookupDbService,
|
||||||
private readonly fanavaranLookupService: FanavaranLookupService,
|
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() {
|
private activeClientKey() {
|
||||||
return resolveFanavaranClientKey();
|
return resolveFanavaranClientKey();
|
||||||
}
|
}
|
||||||
@@ -182,9 +217,122 @@ export class LookupsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getVehicleKinds(): Promise<any> {
|
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");
|
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> {
|
async getPersonRole(): Promise<any> {
|
||||||
return await this.getClientRemoteLookup("person-role");
|
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>) {
|
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 () => {
|
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