forked from Yara724/api
Compare commits
6 Commits
192d4e72de
...
70d05a7624
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d05a7624 | |||
| 305a2965bf | |||
| c4f3558cda | |||
| e761c4b6b2 | |||
| 7f672541ae | |||
| ab4f667c8d |
@@ -4,7 +4,12 @@ describe("FanavaranAuthService", () => {
|
|||||||
const createAuthTokenModel = () => {
|
const createAuthTokenModel = () => {
|
||||||
const store = new Map<
|
const store = new Map<
|
||||||
string,
|
string,
|
||||||
{ clientKey: string; authenticationToken: string; expiresAt: Date }
|
{
|
||||||
|
clientKey: string;
|
||||||
|
authenticationToken: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
authFingerprint?: string;
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
return {
|
return {
|
||||||
findOne: jest.fn((query: { clientKey: string }) => ({
|
findOne: jest.fn((query: { clientKey: string }) => ({
|
||||||
@@ -15,13 +20,20 @@ describe("FanavaranAuthService", () => {
|
|||||||
findOneAndUpdate: jest.fn(
|
findOneAndUpdate: jest.fn(
|
||||||
(
|
(
|
||||||
query: { clientKey: string },
|
query: { clientKey: string },
|
||||||
update: { $set: { authenticationToken: string; expiresAt: Date } },
|
update: {
|
||||||
|
$set: {
|
||||||
|
authenticationToken: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
authFingerprint?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
) => ({
|
) => ({
|
||||||
exec: async () => {
|
exec: async () => {
|
||||||
const next = {
|
const next = {
|
||||||
clientKey: query.clientKey,
|
clientKey: query.clientKey,
|
||||||
authenticationToken: update.$set.authenticationToken,
|
authenticationToken: update.$set.authenticationToken,
|
||||||
expiresAt: update.$set.expiresAt,
|
expiresAt: update.$set.expiresAt,
|
||||||
|
authFingerprint: update.$set.authFingerprint,
|
||||||
};
|
};
|
||||||
store.set(query.clientKey, next);
|
store.set(query.clientKey, next);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ interface CachedFanavaranAuth {
|
|||||||
authenticationToken: string;
|
authenticationToken: string;
|
||||||
/** Epoch ms when the cached token should be refreshed. */
|
/** Epoch ms when the cached token should be refreshed. */
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
/** Hash of auth fields used when this token was obtained. */
|
||||||
|
authFingerprint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TenantBackoffState {
|
interface TenantBackoffState {
|
||||||
@@ -178,6 +180,22 @@ export class FanavaranAuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable fingerprint of tenant auth. Any change (location, credentials, …)
|
||||||
|
* forces a fresh GetAppToken+Login even before Tehran midnight.
|
||||||
|
*/
|
||||||
|
static authFingerprint(auth: FanavaranAuthConfig): string {
|
||||||
|
return [
|
||||||
|
auth.appName,
|
||||||
|
auth.secret,
|
||||||
|
auth.username,
|
||||||
|
auth.password,
|
||||||
|
auth.corpId,
|
||||||
|
auth.contractId,
|
||||||
|
auth.location,
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
assertNotInBackoff(clientKey: FanavaranClientKey): void {
|
assertNotInBackoff(clientKey: FanavaranClientKey): void {
|
||||||
const remaining = this.getBackoffRemainingMs(clientKey);
|
const remaining = this.getBackoffRemainingMs(clientKey);
|
||||||
if (remaining <= 0) return;
|
if (remaining <= 0) return;
|
||||||
@@ -195,13 +213,16 @@ export class FanavaranAuthService {
|
|||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.assertNotInBackoff(clientKey);
|
this.assertNotInBackoff(clientKey);
|
||||||
|
const fingerprint = FanavaranAuthService.authFingerprint(
|
||||||
|
getFanavaranClientProfile(clientKey).auth,
|
||||||
|
);
|
||||||
|
|
||||||
if (!options?.forceRefresh) {
|
if (!options?.forceRefresh) {
|
||||||
const memoryHit = this.readMemoryCache(clientKey);
|
const memoryHit = this.readMemoryCache(clientKey, fingerprint);
|
||||||
if (memoryHit) {
|
if (memoryHit) {
|
||||||
return memoryHit;
|
return memoryHit;
|
||||||
}
|
}
|
||||||
const persisted = await this.readPersistedCache(clientKey);
|
const persisted = await this.readPersistedCache(clientKey, fingerprint);
|
||||||
if (persisted) {
|
if (persisted) {
|
||||||
return persisted;
|
return persisted;
|
||||||
}
|
}
|
||||||
@@ -253,6 +274,7 @@ export class FanavaranAuthService {
|
|||||||
auditSession?: FanavaranAuditSession,
|
auditSession?: FanavaranAuditSession,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const profile = getFanavaranClientProfile(clientKey);
|
const profile = getFanavaranClientProfile(clientKey);
|
||||||
|
const fingerprint = FanavaranAuthService.authFingerprint(profile.auth);
|
||||||
const appToken = await this.fetchAppToken(profile.auth, auditSession);
|
const appToken = await this.fetchAppToken(profile.auth, auditSession);
|
||||||
const authenticationToken = await this.fetchLoginToken(
|
const authenticationToken = await this.fetchLoginToken(
|
||||||
appToken,
|
appToken,
|
||||||
@@ -261,7 +283,12 @@ export class FanavaranAuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
|
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
|
||||||
await this.persistToken(clientKey, authenticationToken, expiresAt);
|
await this.persistToken(
|
||||||
|
clientKey,
|
||||||
|
authenticationToken,
|
||||||
|
expiresAt,
|
||||||
|
fingerprint,
|
||||||
|
);
|
||||||
this.clearBackoff(clientKey);
|
this.clearBackoff(clientKey);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
|
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
|
||||||
@@ -271,25 +298,44 @@ export class FanavaranAuthService {
|
|||||||
return authenticationToken;
|
return authenticationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readMemoryCache(clientKey: FanavaranClientKey): string | null {
|
private readMemoryCache(
|
||||||
|
clientKey: FanavaranClientKey,
|
||||||
|
fingerprint: string,
|
||||||
|
): string | null {
|
||||||
const cached = this.tokenCache.get(clientKey);
|
const cached = this.tokenCache.get(clientKey);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (cached.authFingerprint !== fingerprint) {
|
||||||
|
this.logger.log(
|
||||||
|
`[${clientKey}] Auth config changed — discarding in-memory Fanavaran token`,
|
||||||
|
);
|
||||||
|
this.tokenCache.delete(clientKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (cached.expiresAt > Date.now()) {
|
||||||
return cached.authenticationToken;
|
return cached.authenticationToken;
|
||||||
}
|
}
|
||||||
if (cached) {
|
this.tokenCache.delete(clientKey);
|
||||||
this.tokenCache.delete(clientKey);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readPersistedCache(
|
private async readPersistedCache(
|
||||||
clientKey: FanavaranClientKey,
|
clientKey: FanavaranClientKey,
|
||||||
|
fingerprint: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec();
|
const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec();
|
||||||
if (!doc?.authenticationToken || !doc.expiresAt) {
|
if (!doc?.authenticationToken || !doc.expiresAt) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!doc.authFingerprint || doc.authFingerprint !== fingerprint) {
|
||||||
|
this.logger.log(
|
||||||
|
`[${clientKey}] Auth config changed (or legacy token without fingerprint) — discarding persisted Fanavaran token`,
|
||||||
|
);
|
||||||
|
await this.authTokenModel.deleteOne({ clientKey }).exec();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const expiresAt = new Date(doc.expiresAt).getTime();
|
const expiresAt = new Date(doc.expiresAt).getTime();
|
||||||
if (!(expiresAt > Date.now())) {
|
if (!(expiresAt > Date.now())) {
|
||||||
await this.authTokenModel.deleteOne({ clientKey }).exec();
|
await this.authTokenModel.deleteOne({ clientKey }).exec();
|
||||||
@@ -298,6 +344,7 @@ export class FanavaranAuthService {
|
|||||||
this.tokenCache.set(clientKey, {
|
this.tokenCache.set(clientKey, {
|
||||||
authenticationToken: doc.authenticationToken,
|
authenticationToken: doc.authenticationToken,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
authFingerprint: doc.authFingerprint,
|
||||||
});
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date(
|
`[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date(
|
||||||
@@ -318,8 +365,13 @@ export class FanavaranAuthService {
|
|||||||
clientKey: FanavaranClientKey,
|
clientKey: FanavaranClientKey,
|
||||||
authenticationToken: string,
|
authenticationToken: string,
|
||||||
expiresAt: number,
|
expiresAt: number,
|
||||||
|
authFingerprint: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.tokenCache.set(clientKey, { authenticationToken, expiresAt });
|
this.tokenCache.set(clientKey, {
|
||||||
|
authenticationToken,
|
||||||
|
expiresAt,
|
||||||
|
authFingerprint,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await this.authTokenModel
|
await this.authTokenModel
|
||||||
.findOneAndUpdate(
|
.findOneAndUpdate(
|
||||||
@@ -328,6 +380,7 @@ export class FanavaranAuthService {
|
|||||||
$set: {
|
$set: {
|
||||||
authenticationToken,
|
authenticationToken,
|
||||||
expiresAt: new Date(expiresAt),
|
expiresAt: new Date(expiresAt),
|
||||||
|
authFingerprint,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ upsert: true, new: true },
|
{ upsert: true, new: true },
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { Model } from "mongoose";
|
|||||||
import {
|
import {
|
||||||
FANAVARAN_CLIENT_KEYS,
|
FANAVARAN_CLIENT_KEYS,
|
||||||
SEED_FANAVARAN_CLIENT_PROFILES,
|
SEED_FANAVARAN_CLIENT_PROFILES,
|
||||||
|
getFanavaranClientProfile,
|
||||||
setFanavaranClientProfilesCache,
|
setFanavaranClientProfilesCache,
|
||||||
type FanavaranClientKey,
|
type FanavaranClientKey,
|
||||||
type FanavaranClientProfile,
|
type FanavaranClientProfile,
|
||||||
} from "src/core/config/fanavaran-client.config";
|
} from "src/core/config/fanavaran-client.config";
|
||||||
|
import { FanavaranAuthService } from "./fanavaran-auth.service";
|
||||||
import {
|
import {
|
||||||
FanavaranClientConfig,
|
FanavaranClientConfig,
|
||||||
FanavaranClientConfigDocument,
|
FanavaranClientConfigDocument,
|
||||||
@@ -27,6 +29,7 @@ export class FanavaranClientConfigService implements OnModuleInit {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectModel(FanavaranClientConfig.name)
|
@InjectModel(FanavaranClientConfig.name)
|
||||||
private readonly configModel: Model<FanavaranClientConfigDocument>,
|
private readonly configModel: Model<FanavaranClientConfigDocument>,
|
||||||
|
private readonly fanavaranAuthService: FanavaranAuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -56,7 +59,26 @@ export class FanavaranClientConfigService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read Mongo into the runtime profile cache. If any tenant's auth block
|
||||||
|
* changed (location, credentials, …), drop cached Fanavaran tokens so the
|
||||||
|
* next call re-logins instead of waiting for Tehran midnight.
|
||||||
|
*/
|
||||||
async reloadCache(): Promise<void> {
|
async reloadCache(): Promise<void> {
|
||||||
|
const previousFingerprints = new Map<FanavaranClientKey, string>();
|
||||||
|
for (const key of FANAVARAN_CLIENT_KEYS) {
|
||||||
|
try {
|
||||||
|
previousFingerprints.set(
|
||||||
|
key,
|
||||||
|
FanavaranAuthService.authFingerprint(
|
||||||
|
getFanavaranClientProfile(key).auth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// First boot / empty cache — nothing to compare.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const docs = await this.configModel.find().lean().exec();
|
const docs = await this.configModel.find().lean().exec();
|
||||||
const cache: Partial<Record<FanavaranClientKey, FanavaranClientProfile>> =
|
const cache: Partial<Record<FanavaranClientKey, FanavaranClientProfile>> =
|
||||||
{};
|
{};
|
||||||
@@ -83,6 +105,18 @@ export class FanavaranClientConfigService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFanavaranClientProfilesCache(cache);
|
setFanavaranClientProfilesCache(cache);
|
||||||
|
|
||||||
|
for (const key of FANAVARAN_CLIENT_KEYS) {
|
||||||
|
const next = FanavaranAuthService.authFingerprint(cache[key]!.auth);
|
||||||
|
const prev = previousFingerprints.get(key);
|
||||||
|
if (prev && prev !== next) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[${key}] Fanavaran auth config changed — invalidating cached authenticationToken`,
|
||||||
|
);
|
||||||
|
this.fanavaranAuthService.invalidateToken(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Loaded ${docs.length} Fanavaran client config(s) from Mongo into runtime cache`,
|
`Loaded ${docs.length} Fanavaran client config(s) from Mongo into runtime cache`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export class FanavaranAuthToken {
|
|||||||
@Prop({ type: String, required: true })
|
@Prop({ type: String, required: true })
|
||||||
authenticationToken: string;
|
authenticationToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint of auth config used when this token was minted
|
||||||
|
* (appName/secret/user/pass/corp/contract/location). Mismatch → re-login.
|
||||||
|
*/
|
||||||
|
@Prop({ type: String, required: false })
|
||||||
|
authFingerprint?: string;
|
||||||
|
|
||||||
/** When this token should be refreshed (Asia/Tehran midnight). */
|
/** When this token should be refreshed (Asia/Tehran midnight). */
|
||||||
@Prop({ type: Date, required: true, index: true })
|
@Prop({ type: Date, required: true, index: true })
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
|
|||||||
20
src/offline-inquiry/offline-inquiry.module.ts
Normal file
20
src/offline-inquiry/offline-inquiry.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||||
|
import { OfflineInquiryService } from "./offline-inquiry.service";
|
||||||
|
import {
|
||||||
|
OfflineInquiry,
|
||||||
|
OfflineInquirySchema,
|
||||||
|
} from "./schema/offline-inquiry.schema";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: OfflineInquiry.name, schema: OfflineInquirySchema },
|
||||||
|
]),
|
||||||
|
SystemSettingsModule,
|
||||||
|
],
|
||||||
|
providers: [OfflineInquiryService],
|
||||||
|
exports: [OfflineInquiryService],
|
||||||
|
})
|
||||||
|
export class OfflineInquiryModule {}
|
||||||
287
src/offline-inquiry/offline-inquiry.seeds.ts
Normal file
287
src/offline-inquiry/offline-inquiry.seeds.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed payloads for offline plate/policy inquiry.
|
||||||
|
* Inserted into `offlineInquiries` on boot when the unique key is missing.
|
||||||
|
*/
|
||||||
|
export const OFFLINE_INQUIRY_SEEDS: Array<{
|
||||||
|
clientKey: FanavaranClientKey;
|
||||||
|
inquiryType: string;
|
||||||
|
nationalCode: string;
|
||||||
|
plate: {
|
||||||
|
leftDigits: string;
|
||||||
|
centerAlphabet: string;
|
||||||
|
centerDigits: string;
|
||||||
|
ir: string;
|
||||||
|
};
|
||||||
|
plateId: string;
|
||||||
|
label: string;
|
||||||
|
fanavaranDriverId?: number;
|
||||||
|
person?: Record<string, unknown>;
|
||||||
|
insurance?: Record<string, unknown>;
|
||||||
|
inquiry: {
|
||||||
|
source: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
mapped: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
clientKey: "parsian",
|
||||||
|
inquiryType: "thirdPartyPlate",
|
||||||
|
nationalCode: "0651828562",
|
||||||
|
plate: {
|
||||||
|
leftDigits: "56",
|
||||||
|
centerAlphabet: "د",
|
||||||
|
centerDigits: "394",
|
||||||
|
ir: "66",
|
||||||
|
},
|
||||||
|
plateId: "66-56-د-394",
|
||||||
|
label: "روح اله حيدري — Parsian FIRST party (offline seed)",
|
||||||
|
person: {
|
||||||
|
driverBirthday: "13571201",
|
||||||
|
insurerBirthday: 13571201,
|
||||||
|
driverIsInsurer: true,
|
||||||
|
nationalCodeOfDriver: "0651828562",
|
||||||
|
nationalCodeOfInsurer: "0651828562",
|
||||||
|
phoneNumber: "09120723528",
|
||||||
|
},
|
||||||
|
insurance: {
|
||||||
|
coverages: [],
|
||||||
|
policyNumber: null,
|
||||||
|
company: "بيمه پارسيان",
|
||||||
|
financialCeiling: "0",
|
||||||
|
startDate: "1404/10/21",
|
||||||
|
endDate: "1405/10/23",
|
||||||
|
},
|
||||||
|
inquiry: {
|
||||||
|
source: "OFFLINE_SEED_INQUIRY",
|
||||||
|
raw: {
|
||||||
|
success: true,
|
||||||
|
provider: "PARSIAN",
|
||||||
|
trackingCode: "OFFLINE-PARSIAN-0651828562",
|
||||||
|
message: "offline seeded policy by plate",
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
nationalCode: "0651828562",
|
||||||
|
plk1: "56",
|
||||||
|
plk2: "د",
|
||||||
|
plk3: "394",
|
||||||
|
plksrl: "66",
|
||||||
|
CarGrpCod: "2",
|
||||||
|
CmpCod: "8",
|
||||||
|
CmpNam: "بيمه پارسيان",
|
||||||
|
DisFnYrPrcnt: "35",
|
||||||
|
DisLfYrPrcnt: "35",
|
||||||
|
DisPrsnYrPrcnt: "70",
|
||||||
|
FndCst: "0.00",
|
||||||
|
HBgnDte: "1404/10/23",
|
||||||
|
HEndDte: "1405/10/23",
|
||||||
|
HIsuDte: "1404/10/21",
|
||||||
|
InsNam: "روح اله حيدري",
|
||||||
|
LastCmpCod: "8",
|
||||||
|
LastCmpDocNo: "1110/111130/403/002650",
|
||||||
|
MtrNum: "13389030167",
|
||||||
|
NtnlId: "0651828562",
|
||||||
|
PlcyUnqCod: "16018466143",
|
||||||
|
Plk: "394د56 66",
|
||||||
|
PrdDte: "1389",
|
||||||
|
PrntCmpDocNo: "1110/111130/404/003412",
|
||||||
|
ShsNum: "NAAP41FD5BJ301065",
|
||||||
|
TypPlcy: "بيمه ثالث و حوادث",
|
||||||
|
UsgCod: "8",
|
||||||
|
VIN: "IRFC891V7D2301065",
|
||||||
|
VehSysCod: "2",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
mapped: {
|
||||||
|
nationalCode: "0651828562",
|
||||||
|
plk1: "56",
|
||||||
|
plk2: "د",
|
||||||
|
plk3: "394",
|
||||||
|
plksrl: "66",
|
||||||
|
CarGrpCod: "2",
|
||||||
|
CmpCod: "8",
|
||||||
|
CmpNam: "بيمه پارسيان",
|
||||||
|
DisFnYrPrcnt: "35",
|
||||||
|
DisLfYrPrcnt: "35",
|
||||||
|
DisPrsnYrPrcnt: "70",
|
||||||
|
FndCst: "0.00",
|
||||||
|
HBgnDte: "1404/10/23",
|
||||||
|
HEndDte: "1405/10/23",
|
||||||
|
HIsuDte: "1404/10/21",
|
||||||
|
InsNam: "روح اله حيدري",
|
||||||
|
LastCmpCod: "8",
|
||||||
|
LastCmpDocNo: "1110/111130/403/002650",
|
||||||
|
MtrNum: "13389030167",
|
||||||
|
NtnlId: "0651828562",
|
||||||
|
PlcyUnqCod: "16018466143",
|
||||||
|
Plk: "394د56 66",
|
||||||
|
PrdDte: "1389",
|
||||||
|
PrntCmpDocNo: "1110/111130/404/003412",
|
||||||
|
ShsNum: "NAAP41FD5BJ301065",
|
||||||
|
TypPlcy: "بيمه ثالث و حوادث",
|
||||||
|
UsgCod: "8",
|
||||||
|
VIN: "IRFC891V7D2301065",
|
||||||
|
VehSysCod: "2",
|
||||||
|
companyId: "8",
|
||||||
|
companyPersianName: "بيمه پارسيان",
|
||||||
|
carGrpCod: "2",
|
||||||
|
usgCod: "8",
|
||||||
|
vehSysCod: "2",
|
||||||
|
mtrnum: "13389030167",
|
||||||
|
shsNam: "NAAP41FD5BJ301065",
|
||||||
|
vin: "IRFC891V7D2301065",
|
||||||
|
ntnlId: "0651828562",
|
||||||
|
Name: "روح اله حيدري",
|
||||||
|
ThirdPolicyCode: "16018466143",
|
||||||
|
LastCompanyDocumentNumber: "1110/111130/403/002650",
|
||||||
|
IssueDate: "1404/10/21",
|
||||||
|
StartDate: "1404/10/23",
|
||||||
|
EndDate: "1405/10/23",
|
||||||
|
CompanyCode: "8",
|
||||||
|
CompanyName: "بيمه پارسيان",
|
||||||
|
UsageField: "شخصی",
|
||||||
|
FinancialCvrCptl: "0",
|
||||||
|
PrntPlcyCmpDocNo: "1110/111130/404/003412",
|
||||||
|
VinNumberField: "IRFC891V7D2301065",
|
||||||
|
ChassisNumberField: "NAAP41FD5BJ301065",
|
||||||
|
EngineNumberField: "13389030167",
|
||||||
|
UsageCode: "8",
|
||||||
|
VehicleSystemCode: "2",
|
||||||
|
CarGroupCode: "2",
|
||||||
|
InsuranceFullName: "روح اله حيدري",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clientKey: "parsian",
|
||||||
|
inquiryType: "thirdPartyPlate",
|
||||||
|
nationalCode: "0021557985",
|
||||||
|
plate: {
|
||||||
|
leftDigits: "77",
|
||||||
|
centerAlphabet: "ه",
|
||||||
|
centerDigits: "339",
|
||||||
|
ir: "20",
|
||||||
|
},
|
||||||
|
plateId: "20-77-ه-339",
|
||||||
|
label: "سيدنويد صالحي — Parsian SECOND party (offline seed)",
|
||||||
|
fanavaranDriverId: 2426953,
|
||||||
|
person: {
|
||||||
|
driverBirthday: "13770819",
|
||||||
|
insurerBirthday: 13770819,
|
||||||
|
driverIsInsurer: true,
|
||||||
|
nationalCodeOfDriver: "0021557985",
|
||||||
|
nationalCodeOfInsurer: "0021557985",
|
||||||
|
phoneNumber: "09912536917",
|
||||||
|
},
|
||||||
|
insurance: {
|
||||||
|
coverages: [],
|
||||||
|
policyNumber: null,
|
||||||
|
company: "بيمه پارسيان",
|
||||||
|
financialCeiling: "0",
|
||||||
|
startDate: "1405/02/29",
|
||||||
|
endDate: "1406/02/29",
|
||||||
|
},
|
||||||
|
inquiry: {
|
||||||
|
source: "OFFLINE_SEED_INQUIRY",
|
||||||
|
raw: {
|
||||||
|
success: true,
|
||||||
|
provider: "PARSIAN",
|
||||||
|
trackingCode: "OFFLINE-PARSIAN-0021557985",
|
||||||
|
message: "offline seeded policy by plate",
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
nationalCode: "0021557985",
|
||||||
|
plk1: "77",
|
||||||
|
plk2: "ه",
|
||||||
|
plk3: "339",
|
||||||
|
plksrl: "20",
|
||||||
|
CarGrpCod: "2",
|
||||||
|
CmpCod: "8",
|
||||||
|
CmpNam: "بيمه پارسيان",
|
||||||
|
DisFnYrPrcnt: "15",
|
||||||
|
DisLfYrPrcnt: "15",
|
||||||
|
DisPrsnYrPrcnt: "15",
|
||||||
|
FndCst: "716122.00",
|
||||||
|
HBgnDte: "1405/02/29",
|
||||||
|
HEndDte: "1406/02/29",
|
||||||
|
HIsuDte: "1405/02/29",
|
||||||
|
InsNam: "سيدنويد صالحي",
|
||||||
|
LastCmpCod: "8",
|
||||||
|
LastCmpDocNo: "1110/111130/404/000575",
|
||||||
|
MtrNum: "M159895611",
|
||||||
|
NtnlId: "0021557985",
|
||||||
|
PlcyUnqCod: "16035590431",
|
||||||
|
Plk: "339ه77 20",
|
||||||
|
PrdDte: "1402",
|
||||||
|
PrntCmpDocNo: "1110/111130/405/001098",
|
||||||
|
ShsNum: "NAPX212AAP1173773",
|
||||||
|
TypPlcy: "بيمه ثالث و حوادث",
|
||||||
|
UsgCod: "8",
|
||||||
|
VIN: "NAPX212AAP1173773",
|
||||||
|
VehSysCod: "315",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
mapped: {
|
||||||
|
nationalCode: "0021557985",
|
||||||
|
plk1: "77",
|
||||||
|
plk2: "ه",
|
||||||
|
plk3: "339",
|
||||||
|
plksrl: "20",
|
||||||
|
CarGrpCod: "2",
|
||||||
|
CmpCod: "8",
|
||||||
|
CmpNam: "بيمه پارسيان",
|
||||||
|
DisFnYrPrcnt: "15",
|
||||||
|
DisLfYrPrcnt: "15",
|
||||||
|
DisPrsnYrPrcnt: "15",
|
||||||
|
FndCst: "716122.00",
|
||||||
|
HBgnDte: "1405/02/29",
|
||||||
|
HEndDte: "1406/02/29",
|
||||||
|
HIsuDte: "1405/02/29",
|
||||||
|
InsNam: "سيدنويد صالحي",
|
||||||
|
LastCmpCod: "8",
|
||||||
|
LastCmpDocNo: "1110/111130/404/000575",
|
||||||
|
MtrNum: "M159895611",
|
||||||
|
NtnlId: "0021557985",
|
||||||
|
PlcyUnqCod: "16035590431",
|
||||||
|
Plk: "339ه77 20",
|
||||||
|
PrdDte: "1402",
|
||||||
|
PrntCmpDocNo: "1110/111130/405/001098",
|
||||||
|
ShsNum: "NAPX212AAP1173773",
|
||||||
|
TypPlcy: "بيمه ثالث و حوادث",
|
||||||
|
UsgCod: "8",
|
||||||
|
VIN: "NAPX212AAP1173773",
|
||||||
|
VehSysCod: "315",
|
||||||
|
companyId: "8",
|
||||||
|
companyPersianName: "بيمه پارسيان",
|
||||||
|
carGrpCod: "2",
|
||||||
|
usgCod: "8",
|
||||||
|
vehSysCod: "315",
|
||||||
|
mtrnum: "M159895611",
|
||||||
|
shsNam: "NAPX212AAP1173773",
|
||||||
|
vin: "NAPX212AAP1173773",
|
||||||
|
ntnlId: "0021557985",
|
||||||
|
Name: "سيدنويد صالحي",
|
||||||
|
ThirdPolicyCode: "16035590431",
|
||||||
|
LastCompanyDocumentNumber: "1110/111130/404/000575",
|
||||||
|
IssueDate: "1405/02/29",
|
||||||
|
StartDate: "1405/02/29",
|
||||||
|
EndDate: "1406/02/29",
|
||||||
|
CompanyCode: "8",
|
||||||
|
CompanyName: "بيمه پارسيان",
|
||||||
|
UsageField: "شخصی",
|
||||||
|
FinancialCvrCptl: "0",
|
||||||
|
PrntPlcyCmpDocNo: "1110/111130/405/001098",
|
||||||
|
VinNumberField: "NAPX212AAP1173773",
|
||||||
|
ChassisNumberField: "NAPX212AAP1173773",
|
||||||
|
EngineNumberField: "M159895611",
|
||||||
|
UsageCode: "8",
|
||||||
|
VehicleSystemCode: "315",
|
||||||
|
CarGroupCode: "2",
|
||||||
|
InsuranceFullName: "سيدنويد صالحي",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
154
src/offline-inquiry/offline-inquiry.service.ts
Normal file
154
src/offline-inquiry/offline-inquiry.service.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { Model } from "mongoose";
|
||||||
|
import {
|
||||||
|
resolveFanavaranClientKey,
|
||||||
|
type FanavaranClientKey,
|
||||||
|
} from "src/core/config/fanavaran-client.config";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
|
import { OFFLINE_INQUIRY_SEEDS } from "./offline-inquiry.seeds";
|
||||||
|
import {
|
||||||
|
OfflineInquiry,
|
||||||
|
OfflineInquiryDocument,
|
||||||
|
} from "./schema/offline-inquiry.schema";
|
||||||
|
|
||||||
|
export interface OfflinePlateLookupInput {
|
||||||
|
nationalCode: string;
|
||||||
|
leftDigits: string;
|
||||||
|
centerAlphabet: string;
|
||||||
|
centerDigits: string;
|
||||||
|
ir: string;
|
||||||
|
clientKey?: FanavaranClientKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflinePlateInquiryHit {
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
mapped: Record<string, unknown>;
|
||||||
|
source: string;
|
||||||
|
fanavaranDriverId?: number;
|
||||||
|
insurance?: Record<string, unknown>;
|
||||||
|
person?: Record<string, unknown>;
|
||||||
|
plateId?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OfflineInquiryService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(OfflineInquiryService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(OfflineInquiry.name)
|
||||||
|
private readonly offlineInquiryModel: Model<OfflineInquiryDocument>,
|
||||||
|
private readonly systemSettingsService: SystemSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.seedMissing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize Persian plate letter (strip ZWNJ / Arabic variants). */
|
||||||
|
static normalizePlateAlphabet(value: string): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/[\u200c\u200d\uFEFF]/g, "")
|
||||||
|
.replace(/ي/g, "ی")
|
||||||
|
.replace(/ك/g, "ک")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static normalizeDigits(value: string | number): string {
|
||||||
|
return String(value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedMissing(): Promise<void> {
|
||||||
|
for (const seed of OFFLINE_INQUIRY_SEEDS) {
|
||||||
|
const plate = {
|
||||||
|
leftDigits: OfflineInquiryService.normalizeDigits(seed.plate.leftDigits),
|
||||||
|
centerAlphabet: OfflineInquiryService.normalizePlateAlphabet(
|
||||||
|
seed.plate.centerAlphabet,
|
||||||
|
),
|
||||||
|
centerDigits: OfflineInquiryService.normalizeDigits(
|
||||||
|
seed.plate.centerDigits,
|
||||||
|
),
|
||||||
|
ir: OfflineInquiryService.normalizeDigits(seed.plate.ir),
|
||||||
|
};
|
||||||
|
const existing = await this.offlineInquiryModel
|
||||||
|
.findOne({
|
||||||
|
clientKey: seed.clientKey,
|
||||||
|
inquiryType: seed.inquiryType,
|
||||||
|
nationalCode: seed.nationalCode,
|
||||||
|
"plate.leftDigits": plate.leftDigits,
|
||||||
|
"plate.centerAlphabet": plate.centerAlphabet,
|
||||||
|
"plate.centerDigits": plate.centerDigits,
|
||||||
|
"plate.ir": plate.ir,
|
||||||
|
})
|
||||||
|
.select({ _id: 1 })
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
if (existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.offlineInquiryModel.create({
|
||||||
|
...seed,
|
||||||
|
plate,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Seeded offlineInquiry ${seed.clientKey} ${seed.nationalCode} plate=${seed.plateId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPlateInquiry(
|
||||||
|
input: OfflinePlateLookupInput,
|
||||||
|
): Promise<OfflinePlateInquiryHit | null> {
|
||||||
|
if (!(await this.systemSettingsService.isOfflineInquiryEnabled())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientKey = input.clientKey ?? resolveFanavaranClientKey();
|
||||||
|
const nationalCode = OfflineInquiryService.normalizeDigits(
|
||||||
|
input.nationalCode,
|
||||||
|
);
|
||||||
|
const leftDigits = OfflineInquiryService.normalizeDigits(input.leftDigits);
|
||||||
|
const centerAlphabet = OfflineInquiryService.normalizePlateAlphabet(
|
||||||
|
input.centerAlphabet,
|
||||||
|
);
|
||||||
|
const centerDigits = OfflineInquiryService.normalizeDigits(
|
||||||
|
input.centerDigits,
|
||||||
|
);
|
||||||
|
const ir = OfflineInquiryService.normalizeDigits(input.ir);
|
||||||
|
|
||||||
|
const doc = await this.offlineInquiryModel
|
||||||
|
.findOne({
|
||||||
|
clientKey,
|
||||||
|
inquiryType: "thirdPartyPlate",
|
||||||
|
nationalCode,
|
||||||
|
enabled: true,
|
||||||
|
"plate.leftDigits": leftDigits,
|
||||||
|
"plate.centerAlphabet": centerAlphabet,
|
||||||
|
"plate.centerDigits": centerDigits,
|
||||||
|
"plate.ir": ir,
|
||||||
|
})
|
||||||
|
.lean()
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (!doc?.inquiry?.raw || !doc?.inquiry?.mapped) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[OFFLINE] Hit plate inquiry client=${clientKey} nationalCode=${nationalCode} plate=${doc.plateId ?? `${ir}-${leftDigits}-${centerAlphabet}-${centerDigits}`}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: doc.inquiry.raw as Record<string, unknown>,
|
||||||
|
mapped: doc.inquiry.mapped as Record<string, unknown>,
|
||||||
|
source: doc.inquiry.source || "OFFLINE_SEED_INQUIRY",
|
||||||
|
fanavaranDriverId: doc.fanavaranDriverId,
|
||||||
|
insurance: doc.insurance,
|
||||||
|
person: doc.person,
|
||||||
|
plateId: doc.plateId,
|
||||||
|
label: doc.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/offline-inquiry/schema/offline-inquiry.schema.ts
Normal file
85
src/offline-inquiry/schema/offline-inquiry.schema.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { HydratedDocument } from "mongoose";
|
||||||
|
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
|
||||||
|
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class OfflineInquiryPlateParts {
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
leftDigits: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
centerAlphabet: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
centerDigits: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true })
|
||||||
|
ir: string;
|
||||||
|
}
|
||||||
|
export const OfflineInquiryPlatePartsSchema = SchemaFactory.createForClass(
|
||||||
|
OfflineInquiryPlateParts,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captured plate/policy inquiry (+ optional Fanavaran driver id) for offline use
|
||||||
|
* when live ESG/Tejarat inquiry is unavailable (e.g. Parsian off their LAN).
|
||||||
|
*/
|
||||||
|
@Schema({ collection: "offlineInquiries", timestamps: true })
|
||||||
|
export class OfflineInquiry {
|
||||||
|
@Prop({ type: String, required: true, index: true })
|
||||||
|
clientKey: FanavaranClientKey;
|
||||||
|
|
||||||
|
/** thirdPartyPlate | personalIdentity (extensible). */
|
||||||
|
@Prop({ type: String, required: true, index: true, default: "thirdPartyPlate" })
|
||||||
|
inquiryType: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: true, index: true })
|
||||||
|
nationalCode: string;
|
||||||
|
|
||||||
|
@Prop({ type: OfflineInquiryPlatePartsSchema, required: false })
|
||||||
|
plate?: OfflineInquiryPlateParts;
|
||||||
|
|
||||||
|
/** Human plate id e.g. 66-56-د-394 */
|
||||||
|
@Prop({ type: String, required: false })
|
||||||
|
plateId?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String, required: false })
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
@Prop({ type: Object, required: true })
|
||||||
|
inquiry: {
|
||||||
|
source: string;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
mapped: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Prop({ type: Object, required: false })
|
||||||
|
insurance?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Cached Fanavaran DriverId from parties inquiry-by-unique-identifier. */
|
||||||
|
@Prop({ type: Number, required: false })
|
||||||
|
fanavaranDriverId?: number;
|
||||||
|
|
||||||
|
@Prop({ type: Object, required: false })
|
||||||
|
person?: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, default: true })
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OfflineInquiryDocument = HydratedDocument<OfflineInquiry>;
|
||||||
|
export const OfflineInquirySchema =
|
||||||
|
SchemaFactory.createForClass(OfflineInquiry);
|
||||||
|
|
||||||
|
OfflineInquirySchema.index(
|
||||||
|
{
|
||||||
|
clientKey: 1,
|
||||||
|
inquiryType: 1,
|
||||||
|
nationalCode: 1,
|
||||||
|
"plate.leftDigits": 1,
|
||||||
|
"plate.centerAlphabet": 1,
|
||||||
|
"plate.centerDigits": 1,
|
||||||
|
"plate.ir": 1,
|
||||||
|
},
|
||||||
|
{ unique: true, sparse: true },
|
||||||
|
);
|
||||||
@@ -242,7 +242,7 @@ export class InquiryRefreshService {
|
|||||||
|
|
||||||
if (inquiry.mapped?.Error) {
|
if (inquiry.mapped?.Error) {
|
||||||
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {
|
this.recordPartyInquiry(inquiries, "thirdParty", role, false, {
|
||||||
source: "TEJARAT_BLOCK_INQUIRY",
|
source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY",
|
||||||
raw: inquiry.raw,
|
raw: inquiry.raw,
|
||||||
mapped: inquiry.mapped,
|
mapped: inquiry.mapped,
|
||||||
});
|
});
|
||||||
@@ -252,10 +252,18 @@ export class InquiryRefreshService {
|
|||||||
message: inquiry.mapped.Error.Message || "third-party inquiry error",
|
message: inquiry.mapped.Error.Message || "third-party inquiry error",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
nextParty = this.applyThirdPartyToParty(nextParty, inquiry.raw, inquiry.mapped);
|
nextParty = this.applyThirdPartyToParty(
|
||||||
|
nextParty,
|
||||||
|
inquiry.raw,
|
||||||
|
inquiry.mapped,
|
||||||
|
{
|
||||||
|
source: inquiry.offline?.source,
|
||||||
|
fanavaranDriverId: inquiry.offline?.fanavaranDriverId,
|
||||||
|
},
|
||||||
|
);
|
||||||
partyChanged = true;
|
partyChanged = true;
|
||||||
this.recordPartyInquiry(inquiries, "thirdParty", role, true, {
|
this.recordPartyInquiry(inquiries, "thirdParty", role, true, {
|
||||||
source: "TEJARAT_BLOCK_INQUIRY",
|
source: inquiry.offline?.source ?? "TEJARAT_BLOCK_INQUIRY",
|
||||||
raw: inquiry.raw,
|
raw: inquiry.raw,
|
||||||
mapped: inquiry.mapped,
|
mapped: inquiry.mapped,
|
||||||
refreshedAt: new Date().toISOString(),
|
refreshedAt: new Date().toISOString(),
|
||||||
@@ -343,21 +351,30 @@ export class InquiryRefreshService {
|
|||||||
party: Record<string, any>,
|
party: Record<string, any>,
|
||||||
raw: Record<string, any>,
|
raw: Record<string, any>,
|
||||||
mapped: Record<string, any>,
|
mapped: Record<string, any>,
|
||||||
|
extras?: {
|
||||||
|
source?: string;
|
||||||
|
fanavaranDriverId?: number;
|
||||||
|
},
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
const next = { ...party };
|
const next = { ...party };
|
||||||
next.vehicle = { ...(next.vehicle || {}) };
|
next.vehicle = { ...(next.vehicle || {}) };
|
||||||
next.insurance = { ...(next.insurance || {}) };
|
next.insurance = { ...(next.insurance || {}) };
|
||||||
|
next.person = { ...(next.person || {}) };
|
||||||
|
|
||||||
const existingCarBody = next.vehicle.inquiry?.carBody;
|
const existingCarBody = next.vehicle.inquiry?.carBody;
|
||||||
|
|
||||||
next.vehicle.inquiry = {
|
next.vehicle.inquiry = {
|
||||||
source: "TEJARAT_BLOCK_INQUIRY",
|
source: extras?.source ?? "TEJARAT_BLOCK_INQUIRY",
|
||||||
raw,
|
raw,
|
||||||
mapped,
|
mapped,
|
||||||
refreshedAt: new Date().toISOString(),
|
refreshedAt: new Date().toISOString(),
|
||||||
...(existingCarBody ? { carBody: existingCarBody } : {}),
|
...(existingCarBody ? { carBody: existingCarBody } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (extras?.fanavaranDriverId != null) {
|
||||||
|
next.person.fanavaranDriverId = extras.fanavaranDriverId;
|
||||||
|
}
|
||||||
|
|
||||||
const vehicleName = this.resolveVehicleName(mapped, raw);
|
const vehicleName = this.resolveVehicleName(mapped, raw);
|
||||||
if (vehicleName) {
|
if (vehicleName) {
|
||||||
next.vehicle.name = vehicleName;
|
next.vehicle.name = vehicleName;
|
||||||
|
|||||||
@@ -1223,6 +1223,7 @@ export class RequestManagementService {
|
|||||||
// ---- External inquiry 1: Tejarat block inquiry ----
|
// ---- External inquiry 1: Tejarat block inquiry ----
|
||||||
let inquiryRaw: any;
|
let inquiryRaw: any;
|
||||||
let inquiryMapped: any;
|
let inquiryMapped: any;
|
||||||
|
let inquirySource = "TEJARAT_BLOCK_INQUIRY";
|
||||||
const inquiryClientId = party.person?.clientId
|
const inquiryClientId = party.person?.clientId
|
||||||
? String(party.person.clientId)
|
? String(party.person.clientId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1239,6 +1240,9 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
inquiryRaw = inquiry.raw;
|
inquiryRaw = inquiry.raw;
|
||||||
inquiryMapped = inquiry.mapped;
|
inquiryMapped = inquiry.mapped;
|
||||||
|
if (inquiry.offline?.source) {
|
||||||
|
inquirySource = inquiry.offline.source;
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
|
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
|
||||||
);
|
);
|
||||||
@@ -1246,10 +1250,14 @@ export class RequestManagementService {
|
|||||||
`[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
|
`[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
|
||||||
);
|
);
|
||||||
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
|
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
|
||||||
source: "TEJARAT_BLOCK_INQUIRY",
|
source: inquirySource,
|
||||||
raw: inquiryRaw,
|
raw: inquiryRaw,
|
||||||
mapped: inquiryMapped,
|
mapped: inquiryMapped,
|
||||||
});
|
});
|
||||||
|
if (inquiry.offline?.fanavaranDriverId != null) {
|
||||||
|
if (!party.person) party.person = {} as any;
|
||||||
|
party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId;
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`,
|
`[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`,
|
||||||
@@ -1421,7 +1429,7 @@ export class RequestManagementService {
|
|||||||
party.vehicle.name = inquiryMapped?.MapTypNam;
|
party.vehicle.name = inquiryMapped?.MapTypNam;
|
||||||
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
|
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
|
||||||
party.vehicle.inquiry = {
|
party.vehicle.inquiry = {
|
||||||
source: "TEJARAT_BLOCK_INQUIRY",
|
source: inquirySource,
|
||||||
raw: inquiryRaw,
|
raw: inquiryRaw,
|
||||||
mapped: inquiryMapped,
|
mapped: inquiryMapped,
|
||||||
};
|
};
|
||||||
@@ -8602,6 +8610,7 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
let inquiryRaw: any;
|
let inquiryRaw: any;
|
||||||
let inquiryMapped: any;
|
let inquiryMapped: any;
|
||||||
|
let inquirySource = "TEJARAT_BLOCK_INQUIRY";
|
||||||
try {
|
try {
|
||||||
const inquiry = await this.sandHubService.getTejaratBlockInquiry(
|
const inquiry = await this.sandHubService.getTejaratBlockInquiry(
|
||||||
{
|
{
|
||||||
@@ -8612,11 +8621,18 @@ export class RequestManagementService {
|
|||||||
);
|
);
|
||||||
inquiryRaw = inquiry.raw;
|
inquiryRaw = inquiry.raw;
|
||||||
inquiryMapped = inquiry.mapped;
|
inquiryMapped = inquiry.mapped;
|
||||||
|
if (inquiry.offline?.source) {
|
||||||
|
inquirySource = inquiry.offline.source;
|
||||||
|
}
|
||||||
this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, {
|
this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, {
|
||||||
source: "TEJARAT_BLOCK_INQUIRY",
|
source: inquirySource,
|
||||||
raw: inquiryRaw,
|
raw: inquiryRaw,
|
||||||
mapped: inquiryMapped,
|
mapped: inquiryMapped,
|
||||||
});
|
});
|
||||||
|
if (inquiry.offline?.fanavaranDriverId != null) {
|
||||||
|
if (!party.person) party.person = {} as any;
|
||||||
|
party.person.fanavaranDriverId = inquiry.offline.fanavaranDriverId;
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`,
|
`[V3] plate inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`,
|
||||||
@@ -8679,7 +8695,7 @@ export class RequestManagementService {
|
|||||||
party.vehicle.name = inquiryMapped?.MapTypNam;
|
party.vehicle.name = inquiryMapped?.MapTypNam;
|
||||||
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
|
party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`;
|
||||||
party.vehicle.inquiry = {
|
party.vehicle.inquiry = {
|
||||||
source: "TEJARAT_BLOCK_INQUIRY",
|
source: inquirySource,
|
||||||
raw: inquiryRaw,
|
raw: inquiryRaw,
|
||||||
mapped: inquiryMapped,
|
mapped: inquiryMapped,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MongooseModule } from "@nestjs/mongoose";
|
|||||||
import { ClientModule } from "src/client/client.module";
|
import { ClientModule } from "src/client/client.module";
|
||||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||||
|
import { OfflineInquiryModule } from "src/offline-inquiry/offline-inquiry.module";
|
||||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||||
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
|
import { SandHubModel, SandHubSchema } from "./entity/schema/sand-hub.schema";
|
||||||
import { SandHubService } from "./sand-hub.service";
|
import { SandHubService } from "./sand-hub.service";
|
||||||
@@ -20,6 +21,7 @@ import { SandHubService } from "./sand-hub.service";
|
|||||||
SystemSettingsModule,
|
SystemSettingsModule,
|
||||||
PlateNormalizerModule,
|
PlateNormalizerModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
|
OfflineInquiryModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: SandHubModel.name, schema: SandHubSchema },
|
{ name: SandHubModel.name, schema: SandHubSchema },
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ describe("SandHubService inquiry mocks", () => {
|
|||||||
sandHubDbService as any,
|
sandHubDbService as any,
|
||||||
externalInquirySettings as unknown as ExternalInquirySettingsService,
|
externalInquirySettings as unknown as ExternalInquirySettingsService,
|
||||||
plateNormalizer as any,
|
plateNormalizer as any,
|
||||||
|
{
|
||||||
|
findPlateInquiry: jest.fn().mockResolvedValue(null),
|
||||||
|
} as any,
|
||||||
);
|
);
|
||||||
externalInquirySettings.isInquiryLive.mockResolvedValue(false);
|
externalInquirySettings.isInquiryLive.mockResolvedValue(false);
|
||||||
externalInquirySettings.getMockCompanyContext.mockResolvedValue({
|
externalInquirySettings.getMockCompanyContext.mockResolvedValue({
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { ExternalInquirySettingsService } from "src/client/external-inquiry-sett
|
|||||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||||
import type { ExternalInquiryType } from "src/common/types/external-inquiry.types";
|
import type { ExternalInquiryType } from "src/common/types/external-inquiry.types";
|
||||||
import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types";
|
import type { MockInquiryCompanyContext } from "src/common/types/external-inquiry.types";
|
||||||
|
import { OfflineInquiryService } from "src/offline-inquiry/offline-inquiry.service";
|
||||||
|
import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config";
|
||||||
import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto";
|
import { SandHubDetailDto, SandHubInquiryOptions } from "./dto/sand-hub.dto";
|
||||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
@@ -41,6 +43,7 @@ export class SandHubService {
|
|||||||
private readonly sandHubDbService: SandHubDbService,
|
private readonly sandHubDbService: SandHubDbService,
|
||||||
private readonly externalInquirySettings: ExternalInquirySettingsService,
|
private readonly externalInquirySettings: ExternalInquirySettingsService,
|
||||||
private readonly plateNormalizer: PlateNormalizerService,
|
private readonly plateNormalizer: PlateNormalizerService,
|
||||||
|
private readonly offlineInquiryService: OfflineInquiryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
|
private clientRefFrom(options?: SandHubInquiryOptions): string | undefined {
|
||||||
@@ -731,6 +734,7 @@ export class SandHubService {
|
|||||||
/**
|
/**
|
||||||
* Tejarat block inquiry (replaces SandHub call for V2 flows).
|
* Tejarat block inquiry (replaces SandHub call for V2 flows).
|
||||||
* Returns both raw + mapped (old-format) response.
|
* Returns both raw + mapped (old-format) response.
|
||||||
|
* Offline seeded hits (per Fanavaran clientKey) are checked first.
|
||||||
*/
|
*/
|
||||||
async getTejaratBlockInquiry(
|
async getTejaratBlockInquiry(
|
||||||
userDetail: SandHubDetailDto,
|
userDetail: SandHubDetailDto,
|
||||||
@@ -738,7 +742,34 @@ export class SandHubService {
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
raw: any;
|
raw: any;
|
||||||
mapped: any;
|
mapped: any;
|
||||||
|
offline?: {
|
||||||
|
source: string;
|
||||||
|
fanavaranDriverId?: number;
|
||||||
|
insurance?: Record<string, unknown>;
|
||||||
|
person?: Record<string, unknown>;
|
||||||
|
};
|
||||||
}> {
|
}> {
|
||||||
|
const offlineHit = await this.offlineInquiryService.findPlateInquiry({
|
||||||
|
clientKey: resolveFanavaranClientKey(),
|
||||||
|
nationalCode: String(userDetail.nationalCodeOfInsurer),
|
||||||
|
leftDigits: String(userDetail.plate.leftDigits),
|
||||||
|
centerAlphabet: String(userDetail.plate.centerAlphabet),
|
||||||
|
centerDigits: String(userDetail.plate.centerDigits),
|
||||||
|
ir: String(userDetail.plate.ir),
|
||||||
|
});
|
||||||
|
if (offlineHit) {
|
||||||
|
return {
|
||||||
|
raw: offlineHit.raw,
|
||||||
|
mapped: offlineHit.mapped,
|
||||||
|
offline: {
|
||||||
|
source: offlineHit.source,
|
||||||
|
fanavaranDriverId: offlineHit.fanavaranDriverId,
|
||||||
|
insurance: offlineHit.insurance,
|
||||||
|
person: offlineHit.person,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = await this.mockCompanyContext(options);
|
const ctx = await this.mockCompanyContext(options);
|
||||||
if (this.shouldUseEsgInquiryProvider()) {
|
if (this.shouldUseEsgInquiryProvider()) {
|
||||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||||
|
|||||||
@@ -13,12 +13,38 @@ export class ExternalApisSettingsDto {
|
|||||||
sandHubUseLiveApi?: boolean;
|
sandHubUseLiveApi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OfflineInquirySettingsDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Master switch for seeded offline plate inquiries. When false, `offlineInquiries` hits are skipped.",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateSystemSettingsDto {
|
export class UpdateSystemSettingsDto {
|
||||||
@ApiPropertyOptional({ type: ExternalApisSettingsDto })
|
@ApiPropertyOptional({ type: ExternalApisSettingsDto })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => ExternalApisSettingsDto)
|
@Type(() => ExternalApisSettingsDto)
|
||||||
externalApis?: ExternalApisSettingsDto;
|
externalApis?: ExternalApisSettingsDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: OfflineInquirySettingsDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OfflineInquirySettingsDto)
|
||||||
|
offlineInquiry?: OfflineInquirySettingsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateOfflineInquirySettingsDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Enable or disable offline seeded plate inquiries globally",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExternalApisSettingsViewDto {
|
export class ExternalApisSettingsViewDto {
|
||||||
@@ -30,6 +56,15 @@ export class ExternalApisSettingsViewDto {
|
|||||||
sandHubUseLiveApi: boolean;
|
sandHubUseLiveApi: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OfflineInquirySettingsViewDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Stored in `system_settings.offlineInquiry.enabled` — when false, offline seeds are ignored",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class SystemSettingsResponseDto {
|
export class SystemSettingsResponseDto {
|
||||||
@ApiProperty({ example: "global" })
|
@ApiProperty({ example: "global" })
|
||||||
key: string;
|
key: string;
|
||||||
@@ -37,6 +72,9 @@ export class SystemSettingsResponseDto {
|
|||||||
@ApiProperty({ type: ExternalApisSettingsViewDto })
|
@ApiProperty({ type: ExternalApisSettingsViewDto })
|
||||||
externalApis: ExternalApisSettingsViewDto;
|
externalApis: ExternalApisSettingsViewDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: OfflineInquirySettingsViewDto })
|
||||||
|
offlineInquiry: OfflineInquirySettingsViewDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: "Human-readable mode for operators",
|
description: "Human-readable mode for operators",
|
||||||
example: "mock",
|
example: "mock",
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ export class ExternalApisSettings {
|
|||||||
sandHubUseLiveApi?: boolean;
|
sandHubUseLiveApi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master switch for seeded offline plate inquiries (`offlineInquiries` collection).
|
||||||
|
* When false, lookup is skipped and live/mock inquiry paths run as usual.
|
||||||
|
* Default true when unset (seeds remain usable without an explicit migrate).
|
||||||
|
*/
|
||||||
|
export class OfflineInquirySettings {
|
||||||
|
@Prop({ type: Boolean, required: false, default: true })
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Schema({ collection: "system_settings", versionKey: false })
|
@Schema({ collection: "system_settings", versionKey: false })
|
||||||
export class SystemSettingsModel {
|
export class SystemSettingsModel {
|
||||||
@Prop({ required: true, unique: true, default: SYSTEM_SETTINGS_GLOBAL_KEY })
|
@Prop({ required: true, unique: true, default: SYSTEM_SETTINGS_GLOBAL_KEY })
|
||||||
@@ -24,6 +34,9 @@ export class SystemSettingsModel {
|
|||||||
|
|
||||||
@Prop({ type: ExternalApisSettings, required: false, default: {} })
|
@Prop({ type: ExternalApisSettings, required: false, default: {} })
|
||||||
externalApis?: ExternalApisSettings;
|
externalApis?: ExternalApisSettings;
|
||||||
|
|
||||||
|
@Prop({ type: OfflineInquirySettings, required: false, default: {} })
|
||||||
|
offlineInquiry?: OfflineInquirySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SystemSettingsSchema =
|
export const SystemSettingsSchema =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Patch, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -10,11 +10,23 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
|||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import {
|
import {
|
||||||
|
OfflineInquirySettingsViewDto,
|
||||||
SystemSettingsResponseDto,
|
SystemSettingsResponseDto,
|
||||||
|
UpdateOfflineInquirySettingsDto,
|
||||||
UpdateSystemSettingsDto,
|
UpdateSystemSettingsDto,
|
||||||
} from "./dto/system-settings.dto";
|
} from "./dto/system-settings.dto";
|
||||||
import { SystemSettingsService } from "./system-settings.service";
|
import { SystemSettingsService } from "./system-settings.service";
|
||||||
|
|
||||||
|
const EXPERT_AND_ADMIN_ROLES = [
|
||||||
|
RoleEnum.ADMIN,
|
||||||
|
RoleEnum.SUPER_ADMIN,
|
||||||
|
RoleEnum.EXPERT,
|
||||||
|
RoleEnum.DAMAGE_EXPERT,
|
||||||
|
RoleEnum.FIELD_EXPERT,
|
||||||
|
RoleEnum.FILE_MAKER,
|
||||||
|
RoleEnum.FILE_REVIEWER,
|
||||||
|
] as const;
|
||||||
|
|
||||||
@ApiTags("system-settings")
|
@ApiTags("system-settings")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller("system-settings")
|
@Controller("system-settings")
|
||||||
@@ -27,7 +39,7 @@ export class SystemSettingsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get global system settings (external API toggles)",
|
summary: "Get global system settings (external API toggles)",
|
||||||
description:
|
description:
|
||||||
"Shows whether SandHub/Tejarat live HTTP is enabled. When disabled, the API uses mocks so flows continue without external connectivity.",
|
"Shows whether SandHub/Tejarat live HTTP is enabled, and whether offline seeded inquiries are enabled.",
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
||||||
getSettings(): Promise<SystemSettingsResponseDto> {
|
getSettings(): Promise<SystemSettingsResponseDto> {
|
||||||
@@ -40,7 +52,7 @@ export class SystemSettingsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Update global system settings",
|
summary: "Update global system settings",
|
||||||
description:
|
description:
|
||||||
"Set `externalApis.sandHubUseLiveApi` to true for live inquiries, false for mock/offline mode.",
|
"Set `externalApis.sandHubUseLiveApi` and/or `offlineInquiry.enabled`.",
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
||||||
updateSettings(
|
updateSettings(
|
||||||
@@ -48,4 +60,55 @@ export class SystemSettingsController {
|
|||||||
): Promise<SystemSettingsResponseDto> {
|
): Promise<SystemSettingsResponseDto> {
|
||||||
return this.systemSettingsService.updateSettings(body);
|
return this.systemSettingsService.updateSettings(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("fanavaran-config/reload")
|
||||||
|
@UseGuards(SettingsJwtGuard, RolesGuard)
|
||||||
|
@Roles(...EXPERT_AND_ADMIN_ROLES)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Reload Fanavaran client config cache from Mongo",
|
||||||
|
description:
|
||||||
|
"Re-reads `fanavaranClientConfigs` into the runtime profile cache. If auth fields changed, cached Fanavaran tokens are invalidated so the next call re-logins. Allowed for admin and expert roles.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Cache reloaded",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ok: { type: "boolean", example: true },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reloadFanavaranConfigCache(): Promise<{ ok: true; message: string }> {
|
||||||
|
return this.systemSettingsService.reloadFanavaranConfigCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("offline-inquiry")
|
||||||
|
@UseGuards(SettingsJwtGuard, RolesGuard)
|
||||||
|
@Roles(...EXPERT_AND_ADMIN_ROLES)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get offline inquiry master switch",
|
||||||
|
description:
|
||||||
|
"When enabled, matching `offlineInquiries` seeds short-circuit live plate inquiry. When disabled, live/mock paths run as usual.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: OfflineInquirySettingsViewDto })
|
||||||
|
getOfflineInquirySettings(): Promise<OfflineInquirySettingsViewDto> {
|
||||||
|
return this.systemSettingsService.getOfflineInquirySettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("offline-inquiry")
|
||||||
|
@UseGuards(SettingsJwtGuard, RolesGuard)
|
||||||
|
@Roles(...EXPERT_AND_ADMIN_ROLES)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Enable or disable offline seeded plate inquiries",
|
||||||
|
description:
|
||||||
|
"Global master switch stored in `system_settings.offlineInquiry.enabled`. Per-seed `enabled` flags still apply when this is on.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: OfflineInquirySettingsViewDto })
|
||||||
|
updateOfflineInquirySettings(
|
||||||
|
@Body() body: UpdateOfflineInquirySettingsDto,
|
||||||
|
): Promise<OfflineInquirySettingsViewDto> {
|
||||||
|
return this.systemSettingsService.updateOfflineInquirySettings(body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||||
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
|
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
|
||||||
import {
|
import {
|
||||||
SystemSettingsModel,
|
SystemSettingsModel,
|
||||||
@@ -13,6 +14,7 @@ import { SystemSettingsService } from "./system-settings.service";
|
|||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: SystemSettingsModel.name, schema: SystemSettingsSchema },
|
{ name: SystemSettingsModel.name, schema: SystemSettingsSchema },
|
||||||
]),
|
]),
|
||||||
|
FanavaranLookupModule,
|
||||||
],
|
],
|
||||||
controllers: [SystemSettingsController],
|
controllers: [SystemSettingsController],
|
||||||
providers: [SystemSettingsService, SystemSettingsDbService],
|
providers: [SystemSettingsService, SystemSettingsDbService],
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ describe("SystemSettingsService", () => {
|
|||||||
findGlobal: jest.fn(),
|
findGlobal: jest.fn(),
|
||||||
upsertGlobal: jest.fn(),
|
upsertGlobal: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const fanavaranClientConfigService = {
|
||||||
|
reloadCache: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
let service: SystemSettingsService;
|
let service: SystemSettingsService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
service = new SystemSettingsService(db as any);
|
service = new SystemSettingsService(
|
||||||
|
db as any,
|
||||||
|
fanavaranClientConfigService as any,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to mock when DB field is false", async () => {
|
it("defaults to mock when DB field is false", async () => {
|
||||||
@@ -28,4 +34,41 @@ describe("SystemSettingsService", () => {
|
|||||||
});
|
});
|
||||||
await expect(service.isSandHubLiveEnabled()).resolves.toBe(true);
|
await expect(service.isSandHubLiveEnabled()).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats unset offlineInquiry as enabled", async () => {
|
||||||
|
db.findGlobal.mockResolvedValue({
|
||||||
|
key: "global",
|
||||||
|
externalApis: { sandHubUseLiveApi: false },
|
||||||
|
});
|
||||||
|
await expect(service.isOfflineInquiryEnabled()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables offline inquiry when explicitly false", async () => {
|
||||||
|
db.findGlobal.mockResolvedValue({
|
||||||
|
key: "global",
|
||||||
|
offlineInquiry: { enabled: false },
|
||||||
|
});
|
||||||
|
await expect(service.isOfflineInquiryEnabled()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates offline inquiry master switch", async () => {
|
||||||
|
db.upsertGlobal.mockResolvedValue({
|
||||||
|
key: "global",
|
||||||
|
offlineInquiry: { enabled: false },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.updateOfflineInquirySettings({ enabled: false }),
|
||||||
|
).resolves.toEqual({ enabled: false });
|
||||||
|
expect(db.upsertGlobal).toHaveBeenCalledWith({
|
||||||
|
$set: { "offlineInquiry.enabled": false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads Fanavaran client config cache", async () => {
|
||||||
|
await expect(service.reloadFanavaranConfigCache()).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
message: "Fanavaran client config cache reloaded",
|
||||||
|
});
|
||||||
|
expect(fanavaranClientConfigService.reloadCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { FanavaranClientConfigService } from "src/fanavaran/fanavaran-client-config.service";
|
||||||
import {
|
import {
|
||||||
|
OfflineInquirySettingsViewDto,
|
||||||
SystemSettingsResponseDto,
|
SystemSettingsResponseDto,
|
||||||
|
UpdateOfflineInquirySettingsDto,
|
||||||
UpdateSystemSettingsDto,
|
UpdateSystemSettingsDto,
|
||||||
} from "./dto/system-settings.dto";
|
} from "./dto/system-settings.dto";
|
||||||
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
|
import { SystemSettingsDbService } from "./entities/db-service/system-settings.db.service";
|
||||||
@@ -12,7 +15,10 @@ export class SystemSettingsService {
|
|||||||
private cacheAt = 0;
|
private cacheAt = 0;
|
||||||
private readonly cacheTtlMs = 15_000;
|
private readonly cacheTtlMs = 15_000;
|
||||||
|
|
||||||
constructor(private readonly db: SystemSettingsDbService) {}
|
constructor(
|
||||||
|
private readonly db: SystemSettingsDbService,
|
||||||
|
private readonly fanavaranClientConfigService: FanavaranClientConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private async loadGlobal(): Promise<Record<string, unknown>> {
|
private async loadGlobal(): Promise<Record<string, unknown>> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -22,10 +28,13 @@ export class SystemSettingsService {
|
|||||||
let doc = await this.db.findGlobal();
|
let doc = await this.db.findGlobal();
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
doc = await this.db.upsertGlobal({
|
doc = await this.db.upsertGlobal({
|
||||||
$set: { externalApis: { sandHubUseLiveApi: false } },
|
$set: {
|
||||||
|
externalApis: { sandHubUseLiveApi: false },
|
||||||
|
offlineInquiry: { enabled: true },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
"Created default system_settings document (sandHubUseLiveApi=false)",
|
"Created default system_settings document (sandHubUseLiveApi=false, offlineInquiry.enabled=true)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.cache = doc;
|
this.cache = doc;
|
||||||
@@ -38,6 +47,14 @@ export class SystemSettingsService {
|
|||||||
this.cacheAt = 0;
|
this.cacheAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readOfflineInquiryEnabled(doc: Record<string, unknown>): boolean {
|
||||||
|
const offlineInquiry = doc.offlineInquiry as
|
||||||
|
| { enabled?: boolean }
|
||||||
|
| undefined;
|
||||||
|
// Unset → enabled (preserve prior always-on seed behavior).
|
||||||
|
return offlineInquiry?.enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether SandHub/Tejarat live HTTP should run (`system_settings` collection).
|
* Whether SandHub/Tejarat live HTTP should run (`system_settings` collection).
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +66,18 @@ export class SystemSettingsService {
|
|||||||
return externalApis?.sandHubUseLiveApi === true;
|
return externalApis?.sandHubUseLiveApi === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether seeded offline plate inquiries may short-circuit live/mock inquiry.
|
||||||
|
*/
|
||||||
|
async isOfflineInquiryEnabled(): Promise<boolean> {
|
||||||
|
const doc = await this.loadGlobal();
|
||||||
|
return this.readOfflineInquiryEnabled(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOfflineInquirySettings(): Promise<OfflineInquirySettingsViewDto> {
|
||||||
|
return { enabled: await this.isOfflineInquiryEnabled() };
|
||||||
|
}
|
||||||
|
|
||||||
async getSettingsView(): Promise<SystemSettingsResponseDto> {
|
async getSettingsView(): Promise<SystemSettingsResponseDto> {
|
||||||
const doc = await this.loadGlobal();
|
const doc = await this.loadGlobal();
|
||||||
const externalApis = doc.externalApis as
|
const externalApis = doc.externalApis as
|
||||||
@@ -58,6 +87,7 @@ export class SystemSettingsService {
|
|||||||
return {
|
return {
|
||||||
key: String(doc.key ?? "global"),
|
key: String(doc.key ?? "global"),
|
||||||
externalApis: { sandHubUseLiveApi },
|
externalApis: { sandHubUseLiveApi },
|
||||||
|
offlineInquiry: { enabled: this.readOfflineInquiryEnabled(doc) },
|
||||||
sandHubMode: sandHubUseLiveApi ? "live" : "mock",
|
sandHubMode: sandHubUseLiveApi ? "live" : "mock",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -69,6 +99,9 @@ export class SystemSettingsService {
|
|||||||
if (body.externalApis?.sandHubUseLiveApi !== undefined) {
|
if (body.externalApis?.sandHubUseLiveApi !== undefined) {
|
||||||
$set["externalApis.sandHubUseLiveApi"] = body.externalApis.sandHubUseLiveApi;
|
$set["externalApis.sandHubUseLiveApi"] = body.externalApis.sandHubUseLiveApi;
|
||||||
}
|
}
|
||||||
|
if (body.offlineInquiry?.enabled !== undefined) {
|
||||||
|
$set["offlineInquiry.enabled"] = body.offlineInquiry.enabled;
|
||||||
|
}
|
||||||
if (Object.keys($set).length > 0) {
|
if (Object.keys($set).length > 0) {
|
||||||
await this.db.upsertGlobal({ $set });
|
await this.db.upsertGlobal({ $set });
|
||||||
this.invalidateCache();
|
this.invalidateCache();
|
||||||
@@ -76,4 +109,30 @@ export class SystemSettingsService {
|
|||||||
}
|
}
|
||||||
return this.getSettingsView();
|
return this.getSettingsView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateOfflineInquirySettings(
|
||||||
|
body: UpdateOfflineInquirySettingsDto,
|
||||||
|
): Promise<OfflineInquirySettingsViewDto> {
|
||||||
|
await this.db.upsertGlobal({
|
||||||
|
$set: { "offlineInquiry.enabled": body.enabled },
|
||||||
|
});
|
||||||
|
this.invalidateCache();
|
||||||
|
this.logger.log(
|
||||||
|
`Offline inquiry master switch set to enabled=${body.enabled}`,
|
||||||
|
);
|
||||||
|
return { enabled: body.enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read `fanavaranClientConfigs` into the in-memory profile cache.
|
||||||
|
* Auth fingerprint changes invalidate cached Fanavaran tokens.
|
||||||
|
*/
|
||||||
|
async reloadFanavaranConfigCache(): Promise<{ ok: true; message: string }> {
|
||||||
|
await this.fanavaranClientConfigService.reloadCache();
|
||||||
|
this.logger.log("Fanavaran client config cache reloaded via system-settings API");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: "Fanavaran client config cache reloaded",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user