lookups update per client

This commit is contained in:
2026-06-23 18:23:09 +03:30
parent 16cdf2e7b0
commit c7fb6174a0
8 changed files with 469 additions and 32 deletions

View File

@@ -7,6 +7,7 @@ import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
import { ExpertBlameService } from "./expert-blame.service";
import { RequestManagementModule } from "src/request-management/request-management.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { LookupsModule } from "src/lookups/lookups.module";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
@Module({
@@ -17,6 +18,7 @@ import { ClaimRequestManagementModule } from "src/claim-request-management/claim
PlatesModule,
SmsOrchestrationModule,
ClaimRequestManagementModule,
LookupsModule,
],
controllers: [ExpertBlameController, ExpertBlameV2Controller],
providers: [ExpertBlameService],

View File

@@ -30,6 +30,7 @@ import {
} from "src/helpers/unified-file-status";
import { applyListQueryV2, isInListDateRange, parseListDateRange } from "src/helpers/list-query-v2";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { LookupsService } from "src/lookups/lookups.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
UnifiedFileStatusReportDto,
@@ -126,6 +127,7 @@ export class ExpertBlameService {
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly lookupsService: LookupsService,
) {}
private async loadClaimsByPublicIds(
@@ -2424,21 +2426,7 @@ export class ExpertBlameService {
}
async getAccidentField() {
try {
const ac_reason = await readFile(
"src/static/ACCIDENT_REASON.json",
"utf-8",
);
const ac_type = await readFile("src/static/ACCIDENT_TYPE.json", "utf-8");
const ac_way = await readFile("src/static/ACCIDENT_WAY.json", "utf-8");
return {
accidentReason: JSON.parse(ac_reason),
accidentType: JSON.parse(ac_type),
accidentWay: JSON.parse(ac_way),
};
} catch (err) {
this.logger.error(err);
}
return await this.lookupsService.getAccidentFields();
}
async inPersonVisit(requestId: string, actorDetail: any) {

View File

@@ -0,0 +1,58 @@
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
export const FANAVARAN_LOOKUP_BASE_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0";
export interface FanavaranRemoteLookupDefinition {
name: string;
url: string;
cacheFile: string;
}
/** Fanavaran API lookups synced per client (Parsian fetches on first call). */
export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
{
name: "accident-causes",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/accident-causes`,
cacheFile: "accident-causes.json",
},
{
name: "accident-report-type",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-report-type`,
cacheFile: "accident-report-type.json",
},
{
name: "vehicle-use-types",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-use-types`,
cacheFile: "vehicle-use-types.json",
},
{
name: "dmg-pay-method",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-pay-method`,
cacheFile: "dmg-pay-method.json",
},
{
name: "driving-licence-types",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/driving-licence-types`,
cacheFile: "driving-licence-types.json",
},
{
name: "accident-culprit-type",
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-culprit-type`,
cacheFile: "accident-culprit-type.json",
},
];
export const TEJARAT_STATIC_ACCIDENT_FILES = {
accidentReason: "ACCIDENT_REASON.json",
accidentWay: "ACCIDENT_WAY.json",
accidentType: "ACCIDENT_TYPE.json",
} as const;
export function fanavaranLookupCacheDir(clientKey: FanavaranClientKey): string {
return `${process.cwd()}/files/fanavaran-lookups/${clientKey}`;
}
export function tejaratStaticAccidentFilePath(fileName: string): string {
return `${process.cwd()}/src/static/${fileName}`;
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { FanavaranLookupService } from "./fanavaran-lookup.service";
@Module({
imports: [HttpModule],
providers: [FanavaranLookupService],
exports: [FanavaranLookupService],
})
export class FanavaranLookupModule {}

View File

@@ -0,0 +1,241 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { HttpService } from "@nestjs/axios";
import {
BadGatewayException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { firstValueFrom } from "rxjs";
import { isAxiosError } from "axios";
import {
getFanavaranClientProfile,
type FanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
import {
fanavaranLookupCacheDir,
tejaratStaticAccidentFilePath,
} from "./fanavaran-lookup.config";
@Injectable()
export class FanavaranLookupService {
private readonly logger = new Logger(FanavaranLookupService.name);
private readonly getAppTokenUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
private readonly loginUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
constructor(private readonly httpService: HttpService) {}
private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string {
return join(fanavaranLookupCacheDir(clientKey), fileName);
}
async readCacheFile<T>(
clientKey: FanavaranClientKey,
fileName: string,
): Promise<T | null> {
try {
const content = await readFile(
this.cacheFilePath(clientKey, fileName),
"utf-8",
);
return JSON.parse(content) as T;
} catch {
return null;
}
}
async writeCacheFile(
clientKey: FanavaranClientKey,
fileName: string,
data: unknown,
): Promise<void> {
const dir = fanavaranLookupCacheDir(clientKey);
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, fileName),
`${JSON.stringify(data, null, 2)}\n`,
"utf-8",
);
this.logger.log(
`Cached Fanavaran lookup ${fileName} for client ${clientKey}`,
);
}
async readTejaratStaticAccidentFile<T>(fileName: string): Promise<T> {
const cached = await readFile(tejaratStaticAccidentFilePath(fileName), "utf-8");
return JSON.parse(cached) as T;
}
private async getAppToken(config: {
appName: string;
secret: string;
}): Promise<string> {
const response = await firstValueFrom(
this.httpService.post(this.getAppTokenUrl, "", {
headers: {
appname: config.appName,
secret: config.secret,
"Content-Length": "0",
},
transformRequest: [
(_data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
],
}),
);
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
throw new BadGatewayException("Failed to get Fanavaran appToken");
}
return appToken;
}
private async login(
appToken: string,
config: { username: string; password: string },
): Promise<string> {
const response = await firstValueFrom(
this.httpService.post(this.loginUrl, "", {
headers: {
appToken,
userName: config.username,
password: config.password,
"Content-Length": "0",
},
transformRequest: [
(_data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
],
}),
);
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
if (!authenticationToken) {
throw new BadGatewayException("Failed to get Fanavaran authenticationToken");
}
return authenticationToken;
}
async fetchFromFanavaran(
clientKey: FanavaranClientKey,
url: string,
): Promise<unknown> {
const profile = getFanavaranClientProfile(clientKey);
try {
const appToken = await this.getAppToken(profile.auth);
const authenticationToken = await this.login(appToken, profile.auth);
const response = await firstValueFrom(
this.httpService.get(url, {
headers: {
authenticationToken,
CorpId: profile.auth.corpId,
ContractId: profile.auth.contractId,
Location: profile.auth.location,
"Content-Type": "application/json",
},
timeout: 20000,
}),
);
return response.data;
} catch (error) {
const message = isAxiosError(error)
? error.response?.data?.Message ||
error.response?.data?.message ||
error.message
: error instanceof Error
? error.message
: "Fanavaran lookup request failed";
this.logger.error(
`Fanavaran lookup fetch failed for ${clientKey} (${url}): ${message}`,
);
throw new BadGatewayException(String(message));
}
}
async getRemoteLookup(
clientKey: FanavaranClientKey,
url: string,
cacheFile: string,
fallback?: () => Promise<unknown>,
): Promise<unknown> {
const cached = await this.readCacheFile(clientKey, cacheFile);
if (cached !== null) {
return cached;
}
try {
const data = await this.fetchFromFanavaran(clientKey, url);
await this.writeCacheFile(clientKey, cacheFile, data);
return data;
} catch (error) {
if (fallback) {
this.logger.warn(
`Fanavaran lookup fetch failed for ${clientKey}/${cacheFile}; using fallback`,
);
const data = await fallback();
await this.writeCacheFile(clientKey, cacheFile, data);
return data;
}
throw error;
}
}
mapFanavaranAccidentCausesToReasonOptions(
causes: unknown,
): { id: number; label: string; fanavaran: number }[] {
if (!Array.isArray(causes)) {
throw new NotFoundException("Fanavaran accident-causes response is invalid");
}
return causes
.filter(
(item) =>
item &&
typeof item === "object" &&
(item as { IsActive?: number }).IsActive === 1 &&
typeof (item as { Id?: unknown }).Id === "number",
)
.map((item) => {
const row = item as { Id: number; Caption?: string };
return {
id: row.Id,
label: row.Caption ?? String(row.Id),
fanavaran: row.Id,
};
});
}
}

View File

@@ -163,6 +163,7 @@ export class LookupsController {
schema: {
type: "object",
properties: {
client: { type: "string", example: "tejaratno" },
accidentWay: {
type: "array",
items: {

View File

@@ -1,5 +1,6 @@
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 { LookupModel, LookupSchema } from "./entities/schema/lookup.schema";
import { LookupsController } from "./lookups.controller";
@@ -7,6 +8,7 @@ import { LookupsService } from "./lookups.service";
@Module({
imports: [
FanavaranLookupModule,
MongooseModule.forFeature([
{ name: LookupModel.name, schema: LookupSchema },
]),

View File

@@ -1,15 +1,47 @@
import { readFile } from "node:fs/promises";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config";
import {
FANAVARAN_REMOTE_LOOKUPS,
TEJARAT_STATIC_ACCIDENT_FILES,
} from "src/fanavaran/fanavaran-lookup.config";
import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service";
import { LookupDbService } from "./entities/db-service/lookup.db.service";
type TejaratAccidentReasonRow = {
id: number;
persianLabel: string;
fanavaranID: number;
};
type TejaratAccidentLabelRow = {
id: number;
persianLabel: string;
};
@Injectable()
export class LookupsService {
private readonly logger = new Logger(LookupsService.name);
constructor(private readonly lookupDbService: LookupDbService) {}
constructor(
private readonly lookupDbService: LookupDbService,
private readonly fanavaranLookupService: FanavaranLookupService,
) {}
private activeClientKey() {
return resolveFanavaranClientKey();
}
private findRemoteLookup(name: string) {
const lookup = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name);
if (!lookup) {
throw new Error(`Unknown Fanavaran remote lookup: ${name}`);
}
return lookup;
}
/**
* Returns stored `response` for a lookup document by `name` in the lookups collection.
* Legacy Tejarat seed data — used as fallback when live fetch is unavailable.
*/
async getLookup(lookupName: string): Promise<any> {
const doc = await this.lookupDbService.findOne({ name: lookupName });
@@ -20,42 +52,114 @@ export class LookupsService {
return doc.response;
}
private async getClientRemoteLookup(lookupName: string): Promise<unknown> {
const clientKey = this.activeClientKey();
const definition = this.findRemoteLookup(lookupName);
return this.fanavaranLookupService.getRemoteLookup(
clientKey,
definition.url,
definition.cacheFile,
clientKey === "tejaratno"
? async () => this.getLookup(lookupName)
: undefined,
);
}
async getAccidentCauses(): Promise<any> {
return await this.getLookup("accident-causes");
return await this.getClientRemoteLookup("accident-causes");
}
async getAccidentReportType(): Promise<any> {
return await this.getLookup("accident-report-type");
return await this.getClientRemoteLookup("accident-report-type");
}
async getVehicleUseTypes(): Promise<any> {
return await this.getLookup("vehicle-use-types");
return await this.getClientRemoteLookup("vehicle-use-types");
}
async getDmgPayMethod(): Promise<any> {
return await this.getLookup("dmg-pay-method");
return await this.getClientRemoteLookup("dmg-pay-method");
}
async getDrivingLicenceTypes(): Promise<any> {
return await this.getLookup("driving-licence-types");
return await this.getClientRemoteLookup("driving-licence-types");
}
async getAccidentCulpritType(): Promise<any> {
return await this.getLookup("accident-culprit-type");
return await this.getClientRemoteLookup("accident-culprit-type");
}
async getAccidentWay(): Promise<{ id: number; label: string }[]> {
const raw: { id: number; persianLabel: string }[] = JSON.parse(
await readFile("src/static/ACCIDENT_WAY.json", "utf-8"),
const clientKey = this.activeClientKey();
const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentWay;
const cached = await this.fanavaranLookupService.readCacheFile<
TejaratAccidentLabelRow[]
>(clientKey, fileName);
const raw =
cached ??
(clientKey === "tejaratno"
? await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName)
: null);
if (!raw) {
this.logger.warn(
`No accident-way lookup for client ${clientKey}; falling back to tejarat static file`,
);
const fallback =
await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName);
return fallback.map((item) => ({
id: item.id,
label: item.persianLabel,
}));
}
return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
}
async getAccidentReason(): Promise<
{ id: number; label: string; fanavaran: number }[]
> {
const raw: { id: number; persianLabel: string; fanavaranID: number }[] =
JSON.parse(await readFile("src/static/ACCIDENT_REASON.json", "utf-8"));
const clientKey = this.activeClientKey();
if (clientKey === "parsian") {
const cacheFile = "accident-reason-options.json";
const cached =
await this.fanavaranLookupService.readCacheFile<
{ id: number; label: string; fanavaran: number }[]
>(clientKey, cacheFile);
if (cached) {
return cached;
}
const causes = await this.getAccidentCauses();
const mapped =
this.fanavaranLookupService.mapFanavaranAccidentCausesToReasonOptions(
causes,
);
await this.fanavaranLookupService.writeCacheFile(
clientKey,
cacheFile,
mapped,
);
return mapped;
}
const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentReason;
const cached = await this.fanavaranLookupService.readCacheFile<
TejaratAccidentReasonRow[]
>(clientKey, fileName);
const raw =
cached ??
(await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentReasonRow[]
>(fileName));
return raw.map((item) => ({
id: item.id,
label: item.persianLabel,
@@ -64,9 +168,34 @@ export class LookupsService {
}
async getAccidentType(): Promise<{ id: number; label: string }[]> {
const raw: { id: number; persianLabel: string }[] = JSON.parse(
await readFile("src/static/ACCIDENT_TYPE.json", "utf-8"),
const clientKey = this.activeClientKey();
const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentType;
const cached = await this.fanavaranLookupService.readCacheFile<
TejaratAccidentLabelRow[]
>(clientKey, fileName);
const raw =
cached ??
(clientKey === "tejaratno"
? await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName)
: null);
if (!raw) {
this.logger.warn(
`No accident-type lookup for client ${clientKey}; falling back to tejarat static file`,
);
const fallback =
await this.fanavaranLookupService.readTejaratStaticAccidentFile<
TejaratAccidentLabelRow[]
>(fileName);
return fallback.map((item) => ({
id: item.id,
label: item.persianLabel,
}));
}
return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
}
@@ -74,12 +203,18 @@ export class LookupsService {
accidentWay: { id: number; label: string }[];
accidentReason: { id: number; label: string; fanavaran: number }[];
accidentType: { id: number; label: string }[];
client: string;
}> {
const [accidentWay, accidentReason, accidentType] = await Promise.all([
this.getAccidentWay(),
this.getAccidentReason(),
this.getAccidentType(),
]);
return { accidentWay, accidentReason, accidentType };
return {
client: this.activeClientKey(),
accidentWay,
accidentReason,
accidentType,
};
}
}