Compare commits

..

11 Commits

20 changed files with 2589 additions and 589 deletions

View File

@@ -58,6 +58,7 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module"; import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module"; import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({ @Module({
imports: [ imports: [
@@ -76,6 +77,7 @@ import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normaliz
SandHubModule, SandHubModule,
ClientModule, ClientModule,
MediaPolicyModule, MediaPolicyModule,
SmsOrchestrationModule,
JwtModule.register({}), JwtModule.register({}),
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: ClaimCase.name, schema: ClaimCaseSchema }, { name: ClaimCase.name, schema: ClaimCaseSchema },

View File

@@ -81,6 +81,36 @@ export class FanavaranSyncStage {
@Prop({ type: Number }) @Prop({ type: Number })
expertiseId?: number; expertiseId?: number;
/** Cached Fanavaran PolicyId from inquiry-my-policies (guilty party). */
@Prop({ type: Number })
policyId?: number;
/** Cached Fanavaran DriverId (parties inquiry-by-unique-identifier). */
@Prop({ type: Number })
driverId?: number;
/** Cached Fanavaran VehicleKindId. */
@Prop({ type: Number })
vehicleKindId?: number;
/** Cached Fanavaran InsuranceCorpId. */
@Prop({ type: Number })
insuranceCorpId?: number;
/**
* Last successfully built payload for this stage (preview/submit).
* Lets later preview/submit reuse Fanavaran-sourced fields without re-calling.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
lastPayload?: Record<string, unknown>;
@Prop({ type: Date })
lastPayloadBuiltAt?: Date;
/** When we SMS'd the claim owner about Fanavaran claimId/ClaimNo (base claim). */
@Prop({ type: Date })
smsNotifiedAt?: Date;
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] }) @Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
files?: unknown[]; files?: unknown[];

View File

@@ -1,20 +1,26 @@
export type FanavaranClientKey = "parsian" | "tejaratno"; export type FanavaranClientKey = "parsian" | "tejaratno" | "moallem";
export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [ export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
"parsian", "parsian",
"tejaratno", "tejaratno",
"moallem",
] as const; ] as const;
/** Swagger `@ApiParam({ enum })` value — keep in sync with {@link FANAVARAN_CLIENT_KEYS}. */
export const FANAVARAN_CLIENT_SWAGGER_ENUM: FanavaranClientKey[] = [
...FANAVARAN_CLIENT_KEYS,
];
export function isFanavaranClientKey( export function isFanavaranClientKey(
value: string, value: string,
): value is FanavaranClientKey { ): value is FanavaranClientKey {
const normalized = value?.trim().toLowerCase(); const normalized = value?.trim().toLowerCase();
return normalized === "parsian" || normalized === "tejaratno"; return (FANAVARAN_CLIENT_KEYS as readonly string[]).includes(normalized);
} }
export function normalizeFanavaranClientKey(value: string): FanavaranClientKey { export function normalizeFanavaranClientKey(value: string): FanavaranClientKey {
const normalized = value?.trim().toLowerCase(); const normalized = value?.trim().toLowerCase();
if (normalized === "parsian" || normalized === "tejaratno") { if (isFanavaranClientKey(normalized)) {
return normalized; return normalized;
} }
throw new Error( throw new Error(
@@ -36,7 +42,15 @@ export interface FanavaranPayloadDefaults {
AccidentCityId: number; AccidentCityId: number;
AccidentReportTypeId: number; AccidentReportTypeId: number;
AccidentVehicleUsedId: number; AccidentVehicleUsedId: number;
/**
* GEN.03 base claim — کارشناس مسئول پرونده مالی
* (must NOT be the assessor role used on GEN.08).
*/
ClaimExpertId: number; ClaimExpertId: number;
/**
* GEN.08 expertise — کارشناس ارزیاب خسارت ثالث مالی / وکیل معتمد / کارشناس تحقیق
* (Parsian: 29; Tejaratno proven: 2709).
*/
ExpertiseClaimExpertId: number; ExpertiseClaimExpertId: number;
CompensationReferenceId: number; CompensationReferenceId: number;
CulpritLicenceTypeId: number; CulpritLicenceTypeId: number;
@@ -56,7 +70,34 @@ export interface FanavaranClientProfile {
defaults: FanavaranPayloadDefaults; defaults: FanavaranPayloadDefaults;
} }
const FANAVARAN_CLIENT_PROFILES: Record< /**
* Shared codebook-ish defaults. Each tenant must override ClaimExpertId /
* ExpertiseClaimExpertId (they are different Fanavaran roles).
*/
const SHARED_FANAVARAN_DEFAULTS: FanavaranPayloadDefaults = {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 4543092,
ExpertiseClaimExpertId: 2709,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 23,
};
/**
* Seed / code fallback profiles. On app boot these are inserted into
* `fanavaranClientConfigs` only when a key is missing (never overwrite DB edits).
* Runtime reads prefer the in-memory cache loaded from Mongo.
*/
export const SEED_FANAVARAN_CLIENT_PROFILES: Record<
FanavaranClientKey, FanavaranClientKey,
FanavaranClientProfile FanavaranClientProfile
> = { > = {
@@ -72,20 +113,11 @@ const FANAVARAN_CLIENT_PROFILES: Record<
location: "100", location: "100",
}, },
defaults: { defaults: {
AccidentCityId: 701, ...SHARED_FANAVARAN_DEFAULTS,
AccidentReportTypeId: 155, // GEN.03 — کارشناس مسئول پرونده مالی
AccidentVehicleUsedId: 1,
ClaimExpertId: 4543092, ClaimExpertId: 4543092,
ExpertiseClaimExpertId: 4543092, // GEN.08 — ارزیاب (proven successful submit 2026-07-18 ClaimExpertId: 2709)
CompensationReferenceId: 167, ExpertiseClaimExpertId: 2709,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 23, ClaimFileTypeId: 23,
}, },
}, },
@@ -101,29 +133,57 @@ const FANAVARAN_CLIENT_PROFILES: Record<
location: "210050", location: "210050",
}, },
defaults: { defaults: {
AccidentCityId: 701, ...SHARED_FANAVARAN_DEFAULTS,
AccidentReportTypeId: 155, // GEN.03 — مسئول پرونده مالی (Fanavaran example used 154; west branch 4662)
AccidentVehicleUsedId: 1,
ClaimExpertId: 154, ClaimExpertId: 154,
// GEN.08 — ارزیاب خسارت ثالث مالی (رسول کرکی / شعبه غرب → 29)
ExpertiseClaimExpertId: 29, ExpertiseClaimExpertId: 29,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 70, ClaimFileTypeId: 70,
}, },
}, },
moallem: {
key: "moallem",
auth: {
appName: "ItTalie",
secret: "itT@l!3@api",
username: "itTalieUser",
password: "itT@l!3@user",
corpId: "5650",
contractId: "304",
location: "30900",
},
// Expert role ids not confirmed for Moallem yet — start from shared shape.
defaults: {
...SHARED_FANAVARAN_DEFAULTS,
},
},
}; };
/** @deprecated Use SEED_FANAVARAN_CLIENT_PROFILES — kept alias for older imports. */
export const FANAVARAN_CLIENT_PROFILES = SEED_FANAVARAN_CLIENT_PROFILES;
/**
* Runtime cache filled by FanavaranClientConfigService on boot from Mongo.
* Until then (and in unit tests), callers fall back to seed profiles.
*/
let runtimeProfiles: Partial<
Record<FanavaranClientKey, FanavaranClientProfile>
> | null = null;
export function setFanavaranClientProfilesCache(
profiles: Partial<Record<FanavaranClientKey, FanavaranClientProfile>>,
): void {
runtimeProfiles = profiles;
}
export function clearFanavaranClientProfilesCache(): void {
runtimeProfiles = null;
}
/** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */ /** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
export function resolveFanavaranClientKey(): FanavaranClientKey { export function resolveFanavaranClientKey(): FanavaranClientKey {
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase(); const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
if (explicit === "parsian" || explicit === "tejaratno") { if (explicit && isFanavaranClientKey(explicit)) {
return explicit; return explicit;
} }
@@ -136,17 +196,19 @@ export function resolveFanavaranClientKey(): FanavaranClientKey {
} }
export function resolveFanavaranClientProfile(): FanavaranClientProfile { export function resolveFanavaranClientProfile(): FanavaranClientProfile {
return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()]; return getFanavaranClientProfile(resolveFanavaranClientKey());
} }
export function getFanavaranClientProfile( export function getFanavaranClientProfile(
clientKey: FanavaranClientKey, clientKey: FanavaranClientKey,
): FanavaranClientProfile { ): FanavaranClientProfile {
return FANAVARAN_CLIENT_PROFILES[clientKey]; return (
runtimeProfiles?.[clientKey] ?? SEED_FANAVARAN_CLIENT_PROFILES[clientKey]
);
} }
export function listFanavaranClientProfiles(): FanavaranClientProfile[] { export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
return FANAVARAN_CLIENT_KEYS.map((key) => FANAVARAN_CLIENT_PROFILES[key]); return FANAVARAN_CLIENT_KEYS.map((key) => getFanavaranClientProfile(key));
} }
export function fanavaranPreviewPath( export function fanavaranPreviewPath(

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose"; import { InjectModel } from "@nestjs/mongoose";
import { isAxiosError } from "axios"; import { isAxiosError, type AxiosResponse } from "axios";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { Model, Types } from "mongoose"; import { Model, Types } from "mongoose";
import { import {
@@ -16,18 +16,54 @@ export interface RecordFanavaranAuditStepInput {
step: FanavaranAuditStep; step: FanavaranAuditStep;
status: FanavaranAuditStatus; status: FanavaranAuditStatus;
requestUrl?: string; requestUrl?: string;
requestMethod?: string;
httpStatus?: number; httpStatus?: number;
requestHeaders?: Record<string, unknown>;
requestBody?: unknown;
requestMeta?: Record<string, unknown>; requestMeta?: Record<string, unknown>;
responseHeaders?: Record<string, unknown>;
responseBody?: unknown;
responseMeta?: Record<string, unknown>; responseMeta?: Record<string, unknown>;
errorMessage?: string; errorMessage?: string;
errorDetails?: Record<string, unknown>; errorDetails?: Record<string, unknown>;
durationMs?: number; durationMs?: number;
} }
export interface FanavaranHttpExchange {
httpStatus?: number;
requestHeaders?: Record<string, unknown>;
responseHeaders?: Record<string, unknown>;
responseBody?: unknown;
}
@Injectable() @Injectable()
export class FanavaranAuditService { export class FanavaranAuditService {
private readonly logger = new Logger(FanavaranAuditService.name); private readonly logger = new Logger(FanavaranAuditService.name);
/** Max serialized chars kept for request/response bodies in audit docs. */
static readonly BODY_MAX_CHARS = 80_000;
private static readonly SENSITIVE_HEADER_KEYS = new Set([
"password",
"secret",
"authorization",
"authenticationtoken",
"apptoken",
"app-token",
"x-api-key",
"cookie",
"set-cookie",
]);
private static readonly SENSITIVE_BODY_KEYS = new Set([
"password",
"secret",
"authenticationtoken",
"apptoken",
"token",
"files",
]);
constructor( constructor(
@InjectModel(FanavaranAuditLog.name) @InjectModel(FanavaranAuditLog.name)
private readonly auditModel: Model<FanavaranAuditLogDocument>, private readonly auditModel: Model<FanavaranAuditLogDocument>,
@@ -47,6 +83,138 @@ export class FanavaranAuditService {
return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`; return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`;
} }
maskToken(value: string): string {
const trimmed = value.trim();
if (trimmed.length <= 8) {
return "****";
}
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)} (len=${trimmed.length})`;
}
sanitizeHeaders(
headers?: Record<string, unknown> | null,
): Record<string, unknown> | undefined {
if (!headers || typeof headers !== "object") {
return undefined;
}
const out: Record<string, unknown> = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
if (rawValue === undefined) continue;
const key = String(rawKey);
const lower = key.toLowerCase();
if (FanavaranAuditService.SENSITIVE_HEADER_KEYS.has(lower)) {
out[key] =
typeof rawValue === "string"
? this.maskToken(rawValue)
: "***";
continue;
}
if (
typeof rawValue === "string" ||
typeof rawValue === "number" ||
typeof rawValue === "boolean" ||
rawValue === null
) {
out[key] = rawValue;
} else {
out[key] = String(rawValue);
}
}
return out;
}
sanitizeBody(body: unknown): unknown {
if (body === undefined) {
return undefined;
}
const masked = this.maskSensitiveDeep(body);
try {
const serialized = JSON.stringify(masked);
if (serialized.length <= FanavaranAuditService.BODY_MAX_CHARS) {
return masked;
}
return {
_truncated: true,
maxChars: FanavaranAuditService.BODY_MAX_CHARS,
preview: serialized.slice(0, FanavaranAuditService.BODY_MAX_CHARS),
};
} catch {
const asString = String(masked);
return asString.length <= FanavaranAuditService.BODY_MAX_CHARS
? asString
: {
_truncated: true,
preview: asString.slice(0, FanavaranAuditService.BODY_MAX_CHARS),
};
}
}
/**
* Extract request/response headers + body from an Axios response or Axios
* error (uses `config.headers` when present). Values are returned raw;
* `recordStep` applies sanitisation before persistence.
*/
captureAxiosExchange(
source: unknown,
overrideRequestHeaders?: Record<string, unknown>,
): FanavaranHttpExchange {
const toPlainHeaders = (
headers?: Record<string, unknown> | null,
): Record<string, unknown> | undefined => {
if (!headers || typeof headers !== "object") return undefined;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) continue;
out[key] =
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
value === null
? value
: String(value);
}
return out;
};
if (isAxiosError(source)) {
return {
httpStatus: source.response?.status,
requestHeaders: toPlainHeaders(
overrideRequestHeaders ??
(source.config?.headers as Record<string, unknown> | undefined),
),
responseHeaders: toPlainHeaders(
source.response?.headers as Record<string, unknown> | undefined,
),
responseBody: source.response?.data,
};
}
const response = source as AxiosResponse | null;
if (
response &&
typeof response === "object" &&
"status" in response &&
"headers" in response
) {
return {
httpStatus: response.status,
requestHeaders: toPlainHeaders(
overrideRequestHeaders ??
(response.config?.headers as Record<string, unknown> | undefined),
),
responseHeaders: toPlainHeaders(
response.headers as Record<string, unknown> | undefined,
),
responseBody: response.data,
};
}
return {
requestHeaders: toPlainHeaders(overrideRequestHeaders),
};
}
sanitizeErrorDetails(error: unknown): Record<string, unknown> { sanitizeErrorDetails(error: unknown): Record<string, unknown> {
if (isAxiosError(error)) { if (isAxiosError(error)) {
const data = error.response?.data; const data = error.response?.data;
@@ -54,12 +222,13 @@ export class FanavaranAuditService {
type: "axios", type: "axios",
status: error.response?.status, status: error.response?.status,
statusText: error.response?.statusText, statusText: error.response?.statusText,
data: data: this.sanitizeBody(
typeof data === "object" && data !== null typeof data === "object" && data !== null
? data ? data
: typeof data === "string" : typeof data === "string"
? data.slice(0, 2000) ? data.slice(0, 2000)
: data, : data,
),
}; };
} }
if (error instanceof Error) { if (error instanceof Error) {
@@ -112,8 +281,13 @@ export class FanavaranAuditService {
? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) } ? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) }
: {}), : {}),
requestUrl: input.requestUrl, requestUrl: input.requestUrl,
requestMethod: input.requestMethod,
httpStatus: input.httpStatus, httpStatus: input.httpStatus,
requestHeaders: this.sanitizeHeaders(input.requestHeaders),
requestBody: this.sanitizeBody(input.requestBody),
requestMeta: input.requestMeta, requestMeta: input.requestMeta,
responseHeaders: this.sanitizeHeaders(input.responseHeaders),
responseBody: this.sanitizeBody(input.responseBody),
responseMeta: input.responseMeta, responseMeta: input.responseMeta,
errorMessage: input.errorMessage, errorMessage: input.errorMessage,
errorDetails: input.errorDetails, errorDetails: input.errorDetails,
@@ -134,4 +308,31 @@ export class FanavaranAuditService {
.lean() .lean()
.exec(); .exec();
} }
private maskSensitiveDeep(value: unknown, depth = 0): unknown {
if (depth > 8) {
return "[max-depth]";
}
if (value === null || value === undefined) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => this.maskSensitiveDeep(item, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(
value as Record<string, unknown>,
)) {
if (FanavaranAuditService.SENSITIVE_BODY_KEYS.has(key.toLowerCase())) {
out[key] =
typeof child === "string" ? this.maskToken(child) : "[redacted]";
continue;
}
out[key] = this.maskSensitiveDeep(child, depth + 1);
}
return out;
}
return value;
}
} }

View File

@@ -0,0 +1,180 @@
import { FanavaranAuthService } from "./fanavaran-auth.service";
describe("FanavaranAuthService", () => {
const createAuthTokenModel = () => {
const store = new Map<
string,
{ clientKey: string; authenticationToken: string; expiresAt: Date }
>();
return {
findOne: jest.fn((query: { clientKey: string }) => ({
lean: () => ({
exec: async () => store.get(query.clientKey) ?? null,
}),
})),
findOneAndUpdate: jest.fn(
(
query: { clientKey: string },
update: { $set: { authenticationToken: string; expiresAt: Date } },
) => ({
exec: async () => {
const next = {
clientKey: query.clientKey,
authenticationToken: update.$set.authenticationToken,
expiresAt: update.$set.expiresAt,
};
store.set(query.clientKey, next);
return next;
},
}),
),
deleteOne: jest.fn((query: { clientKey: string }) => ({
exec: async () => {
store.delete(query.clientKey);
return { deletedCount: 1 };
},
})),
_store: store,
};
};
it("detects Fanavaran transient try-later messages", () => {
expect(
FanavaranAuthService.isTransientTryLaterError(
"کد پیگیری خطا: 10573755\r\n1405/05/1016:56:16:276\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.",
),
).toBe(true);
expect(
FanavaranAuthService.isTransientTryLaterError(
"فیلد شماره بيمه نامه ضروری میباشد",
),
).toBe(false);
});
it("caches token and single-flights concurrent logins", async () => {
const http = {
post: jest.fn(),
};
const audit = {
recordStep: jest.fn().mockResolvedValue(undefined),
extractErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
sanitizeErrorDetails: () => ({}),
formatErrorWithTrackingCode: (m: string) => m,
captureAxiosExchange: () => ({}),
};
const authTokenModel = createAuthTokenModel();
let loginCalls = 0;
const { of, delay } = await import("rxjs");
http.post.mockImplementation((url: string) => {
if (url.includes("GetAppToken")) {
return of({
status: 200,
headers: { apptoken: "app-1" },
data: {},
});
}
loginCalls += 1;
return of({
status: 200,
headers: { authenticationtoken: "auth-1" },
data: {},
}).pipe(delay(20));
});
const service = new FanavaranAuthService(
http as any,
audit as any,
authTokenModel as any,
);
const [a, b, c] = await Promise.all([
service.getAuthenticationToken("parsian"),
service.getAuthenticationToken("parsian"),
service.getAuthenticationToken("parsian"),
]);
expect(a).toBe("auth-1");
expect(b).toBe("auth-1");
expect(c).toBe("auth-1");
expect(loginCalls).toBe(1);
// Cache hit — no extra login
await service.getAuthenticationToken("parsian");
expect(loginCalls).toBe(1);
});
it("reuses persisted token across service instances", async () => {
const http = { post: jest.fn() };
const audit = {
recordStep: jest.fn().mockResolvedValue(undefined),
extractErrorMessage: (e: unknown) =>
e instanceof Error ? e.message : String(e),
sanitizeErrorDetails: () => ({}),
formatErrorWithTrackingCode: (m: string) => m,
captureAxiosExchange: () => ({}),
};
const authTokenModel = createAuthTokenModel();
const { of } = await import("rxjs");
let loginCalls = 0;
http.post.mockImplementation((url: string) => {
if (url.includes("GetAppToken")) {
return of({
status: 200,
headers: { apptoken: "app-1" },
data: {},
});
}
loginCalls += 1;
return of({
status: 200,
headers: { authenticationtoken: "auth-persisted" },
data: {},
});
});
const first = new FanavaranAuthService(
http as any,
audit as any,
authTokenModel as any,
);
await first.getAuthenticationToken("tejaratno");
expect(loginCalls).toBe(1);
const second = new FanavaranAuthService(
http as any,
audit as any,
authTokenModel as any,
);
const token = await second.getAuthenticationToken("tejaratno");
expect(token).toBe("auth-persisted");
expect(loginCalls).toBe(1);
});
it("enters tenant backoff on try-later errors", () => {
const service = new FanavaranAuthService(
{} as any,
{} as any,
createAuthTokenModel() as any,
);
service.registerFailure(
"parsian",
"کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.",
);
expect(service.isInBackoff("parsian")).toBe(true);
expect(() => service.assertNotInBackoff("parsian")).toThrow(/backoff/i);
});
it("computes next Asia/Tehran midnight expiry after now", () => {
const now = Date.parse("2026-08-02T10:00:00.000Z");
const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now);
expect(expiry).toBeGreaterThan(now);
expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
expect(expiry - now).toBeGreaterThan(0);
const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z");
const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight);
expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000);
});
});

View File

@@ -0,0 +1,567 @@
import { HttpService } from "@nestjs/axios";
import {
BadGatewayException,
HttpException,
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { isAxiosError } from "axios";
import { Model } from "mongoose";
import { firstValueFrom } from "rxjs";
import {
getFanavaranClientProfile,
type FanavaranAuthConfig,
type FanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
import { FanavaranAuditService } from "./fanavaran-audit.service";
import type { FanavaranAuditSession } from "./fanavaran-audit.types";
import {
FanavaranAuditStatus,
FanavaranAuditStep,
} from "./schema/fanavaran-audit-log.schema";
import {
FanavaranAuthToken,
FanavaranAuthTokenDocument,
} from "./schema/fanavaran-auth-token.schema";
interface CachedFanavaranAuth {
authenticationToken: string;
/** Epoch ms when the cached token should be refreshed. */
expiresAt: number;
}
interface TenantBackoffState {
until: number;
reason: string;
}
/** Shared Fanavaran auth: one AppToken+Login per tenant, reused until TTL / backoff. */
@Injectable()
export class FanavaranAuthService {
private readonly logger = new Logger(FanavaranAuthService.name);
private readonly getAppTokenUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
private readonly loginUrl =
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
/** When Fanavaran says "try again later", pause all tenant calls. */
static readonly TRANSIENT_BACKOFF_MS = 5 * 60 * 1000;
/**
* Fanavaran authenticationToken is valid until local midnight (Asia/Tehran).
* First call after 00:00 gets a fresh token; daytime calls reuse the cache.
*/
static readonly TOKEN_TIME_ZONE = "Asia/Tehran";
private readonly tokenCache = new Map<FanavaranClientKey, CachedFanavaranAuth>();
private readonly inflightLogin = new Map<
FanavaranClientKey,
Promise<string>
>();
private readonly backoffByTenant = new Map<
FanavaranClientKey,
TenantBackoffState
>();
constructor(
private readonly httpService: HttpService,
private readonly fanavaranAuditService: FanavaranAuditService,
@InjectModel(FanavaranAuthToken.name)
private readonly authTokenModel: Model<FanavaranAuthTokenDocument>,
) {}
/** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */
static isTransientTryLaterError(errorOrMessage: unknown): boolean {
const message = FanavaranAuthService.extractMessage(errorOrMessage);
if (!message) return false;
return (
message.includes("لطفا پس از چند لحظه مجدد تلاش") ||
message.includes("مجدد تلاش فرمایید") ||
/try again later/i.test(message)
);
}
static extractMessage(errorOrMessage: unknown): string {
if (typeof errorOrMessage === "string") return errorOrMessage;
if (isAxiosError(errorOrMessage)) {
const data = errorOrMessage.response?.data as
| { Message?: string; message?: string }
| string
| undefined;
if (typeof data === "string") return data;
return (
data?.Message ||
data?.message ||
errorOrMessage.message ||
""
);
}
if (errorOrMessage instanceof Error) return errorOrMessage.message;
return errorOrMessage == null ? "" : String(errorOrMessage);
}
/**
* Epoch ms of the next 00:00:00 in Asia/Tehran after `now`.
* If `now` is exactly midnight Tehran, returns the following midnight.
*/
static getNextMidnightExpiryMs(
nowMs: number = Date.now(),
timeZone: string = FanavaranAuthService.TOKEN_TIME_ZONE,
): number {
const parts = Object.fromEntries(
new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
})
.formatToParts(new Date(nowMs))
.filter((p) => p.type !== "literal")
.map((p) => [p.type, p.value]),
) as Record<string, string>;
const hour = Number(parts.hour);
const minute = Number(parts.minute);
const second = Number(parts.second);
const msIntoDay = ((hour * 60 + minute) * 60 + second) * 1000;
const msPerDay = 24 * 60 * 60 * 1000;
const remaining = msPerDay - msIntoDay;
// Exactly at midnight → treat as expired for current day; expire at next midnight.
return nowMs + (remaining === 0 ? msPerDay : remaining);
}
getBackoffRemainingMs(clientKey: FanavaranClientKey): number {
const state = this.backoffByTenant.get(clientKey);
if (!state) return 0;
return Math.max(0, state.until - Date.now());
}
isInBackoff(clientKey: FanavaranClientKey): boolean {
return this.getBackoffRemainingMs(clientKey) > 0;
}
/**
* Call after any Fanavaran HTTP failure. Sets a tenant-wide pause when the
* error is the familiar “try again later” overload response.
*/
registerFailure(clientKey: FanavaranClientKey, error: unknown): void {
if (!FanavaranAuthService.isTransientTryLaterError(error)) return;
const until = Date.now() + FanavaranAuthService.TRANSIENT_BACKOFF_MS;
const reason = FanavaranAuthService.extractMessage(error).slice(0, 500);
this.backoffByTenant.set(clientKey, { until, reason });
// Token may still be valid, but Fanavaran is rejecting work — keep token,
// just stop hammering Login/business APIs.
this.logger.warn(
`[${clientKey}] Fanavaran transient backoff until ${new Date(until).toISOString()}: ${reason}`,
);
}
clearBackoff(clientKey: FanavaranClientKey): void {
this.backoffByTenant.delete(clientKey);
}
invalidateToken(clientKey: FanavaranClientKey): void {
this.tokenCache.delete(clientKey);
void this.authTokenModel.deleteOne({ clientKey }).exec().catch((error) => {
this.logger.warn(
`[${clientKey}] Failed to clear persisted Fanavaran auth token`,
error,
);
});
}
assertNotInBackoff(clientKey: FanavaranClientKey): void {
const remaining = this.getBackoffRemainingMs(clientKey);
if (remaining <= 0) return;
const state = this.backoffByTenant.get(clientKey);
throw new ServiceUnavailableException(
`Fanavaran tenant "${clientKey}" is in backoff for ${Math.ceil(remaining / 1000)}s after transient errors. ${state?.reason ?? ""}`.trim(),
);
}
async getAuthenticationToken(
clientKey: FanavaranClientKey,
options?: {
auditSession?: FanavaranAuditSession;
forceRefresh?: boolean;
},
): Promise<string> {
this.assertNotInBackoff(clientKey);
if (!options?.forceRefresh) {
const memoryHit = this.readMemoryCache(clientKey);
if (memoryHit) {
return memoryHit;
}
const persisted = await this.readPersistedCache(clientKey);
if (persisted) {
return persisted;
}
} else {
this.invalidateToken(clientKey);
}
const existing = this.inflightLogin.get(clientKey);
if (existing) {
return existing;
}
const loginPromise = this.loginFresh(clientKey, options?.auditSession);
this.inflightLogin.set(clientKey, loginPromise);
try {
return await loginPromise;
} finally {
this.inflightLogin.delete(clientKey);
}
}
async getRequestHeaders(
clientKey: FanavaranClientKey,
options?: {
auditSession?: FanavaranAuditSession;
forceRefresh?: boolean;
},
): Promise<{
authenticationToken: string;
CorpId: string;
ContractId: string;
Location: string;
}> {
const profile = getFanavaranClientProfile(clientKey);
const authenticationToken = await this.getAuthenticationToken(
clientKey,
options,
);
return {
authenticationToken,
CorpId: profile.auth.corpId,
ContractId: profile.auth.contractId,
Location: profile.auth.location,
};
}
private async loginFresh(
clientKey: FanavaranClientKey,
auditSession?: FanavaranAuditSession,
): Promise<string> {
const profile = getFanavaranClientProfile(clientKey);
const appToken = await this.fetchAppToken(profile.auth, auditSession);
const authenticationToken = await this.fetchLoginToken(
appToken,
profile.auth,
auditSession,
);
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
await this.persistToken(clientKey, authenticationToken, expiresAt);
this.clearBackoff(clientKey);
this.logger.log(
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
expiresAt,
).toISOString()} (${FanavaranAuthService.TOKEN_TIME_ZONE} midnight)`,
);
return authenticationToken;
}
private readMemoryCache(clientKey: FanavaranClientKey): string | null {
const cached = this.tokenCache.get(clientKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.authenticationToken;
}
if (cached) {
this.tokenCache.delete(clientKey);
}
return null;
}
private async readPersistedCache(
clientKey: FanavaranClientKey,
): Promise<string | null> {
try {
const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec();
if (!doc?.authenticationToken || !doc.expiresAt) {
return null;
}
const expiresAt = new Date(doc.expiresAt).getTime();
if (!(expiresAt > Date.now())) {
await this.authTokenModel.deleteOne({ clientKey }).exec();
return null;
}
this.tokenCache.set(clientKey, {
authenticationToken: doc.authenticationToken,
expiresAt,
});
this.logger.log(
`[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date(
expiresAt,
).toISOString()}`,
);
return doc.authenticationToken;
} catch (error) {
this.logger.warn(
`[${clientKey}] Failed to read persisted Fanavaran auth token`,
error,
);
return null;
}
}
private async persistToken(
clientKey: FanavaranClientKey,
authenticationToken: string,
expiresAt: number,
): Promise<void> {
this.tokenCache.set(clientKey, { authenticationToken, expiresAt });
try {
await this.authTokenModel
.findOneAndUpdate(
{ clientKey },
{
$set: {
authenticationToken,
expiresAt: new Date(expiresAt),
},
},
{ upsert: true, new: true },
)
.exec();
} catch (error) {
this.logger.warn(
`[${clientKey}] Failed to persist Fanavaran auth token (memory cache still active)`,
error,
);
}
}
private emptyBodyTransformRequest() {
return [
(_data: unknown, headers?: Record<string, unknown>) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return _data;
},
];
}
private async fetchAppToken(
config: Pick<FanavaranAuthConfig, "appName" | "secret">,
auditSession?: FanavaranAuditSession,
): Promise<string> {
const startedAt = Date.now();
const requestHeaders = {
appname: config.appName,
secret: config.secret,
"Content-Length": "0",
};
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.STARTED,
requestUrl: this.getAppTokenUrl,
requestMethod: "POST",
requestHeaders,
requestBody: "",
requestMeta: { appName: config.appName, cached: false },
});
}
try {
const response = await firstValueFrom(
this.httpService.post(this.getAppTokenUrl, "", {
headers: requestHeaders,
transformRequest: this.emptyBodyTransformRequest(),
}),
);
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
throw new BadGatewayException("Failed to get Fanavaran appToken");
}
if (auditSession) {
const exchange =
this.fanavaranAuditService.captureAxiosExchange(
response,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.getAppTokenUrl,
requestMethod: "POST",
httpStatus: response.status,
requestHeaders: exchange.requestHeaders,
requestBody: "",
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
responseMeta: { hasAppToken: true, cached: false },
durationMs: Date.now() - startedAt,
});
}
return appToken;
} catch (error) {
if (auditSession) {
const exchange = this.fanavaranAuditService.captureAxiosExchange(
error,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.FAILURE,
requestUrl: this.getAppTokenUrl,
requestMethod: "POST",
httpStatus: exchange.httpStatus,
requestHeaders: exchange.requestHeaders,
requestBody: "",
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt,
});
}
throw this.toGatewayError(error, "Failed to get appToken from external API", auditSession);
}
}
private async fetchLoginToken(
appToken: string,
config: Pick<FanavaranAuthConfig, "username" | "password">,
auditSession?: FanavaranAuditSession,
): Promise<string> {
const startedAt = Date.now();
const requestHeaders = {
appToken,
userName: config.username,
password: config.password,
"Content-Length": "0",
};
if (auditSession) {
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.STARTED,
requestUrl: this.loginUrl,
requestMethod: "POST",
requestHeaders,
requestBody: "",
requestMeta: { userName: config.username, cached: false },
});
}
try {
const response = await firstValueFrom(
this.httpService.post(this.loginUrl, "", {
headers: requestHeaders,
transformRequest: this.emptyBodyTransformRequest(),
}),
);
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",
);
}
if (auditSession) {
const exchange =
this.fanavaranAuditService.captureAxiosExchange(
response,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.loginUrl,
requestMethod: "POST",
httpStatus: response.status,
requestHeaders: exchange.requestHeaders,
requestBody: "",
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
responseMeta: {
hasAuthenticationToken: true,
cached: false,
},
durationMs: Date.now() - startedAt,
});
}
return authenticationToken;
} catch (error) {
if (auditSession) {
const exchange = this.fanavaranAuditService.captureAxiosExchange(
error,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({
session: auditSession,
step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.FAILURE,
requestUrl: this.loginUrl,
requestMethod: "POST",
httpStatus: exchange.httpStatus,
requestHeaders: exchange.requestHeaders,
requestBody: "",
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt,
});
}
throw this.toGatewayError(error, "Failed to login to external API", auditSession);
}
}
private toGatewayError(
error: unknown,
fallback: string,
auditSession?: FanavaranAuditSession,
): HttpException {
if (error instanceof HttpException) {
return error;
}
const message = isAxiosError(error)
? error.response?.data?.Message ||
error.response?.data?.message ||
error.message ||
fallback
: fallback;
return new BadGatewayException(
this.fanavaranAuditService.formatErrorWithTrackingCode(
String(message),
auditSession?.trackingCode,
),
);
}
}

View File

@@ -0,0 +1,98 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import {
FANAVARAN_CLIENT_KEYS,
SEED_FANAVARAN_CLIENT_PROFILES,
setFanavaranClientProfilesCache,
type FanavaranClientKey,
type FanavaranClientProfile,
} from "src/core/config/fanavaran-client.config";
import {
FanavaranClientConfig,
FanavaranClientConfigDocument,
} from "./schema/fanavaran-client-config.schema";
/**
* Loads / seeds per-tenant Fanavaran auth+defaults from Mongo and exposes them
* through the sync helpers in fanavaran-client.config.ts.
*
* Boot behaviour: for each known client key, if no document exists, insert the
* seed profile. Existing documents are never overwritten (ops can edit Mongo).
*/
@Injectable()
export class FanavaranClientConfigService implements OnModuleInit {
private readonly logger = new Logger(FanavaranClientConfigService.name);
constructor(
@InjectModel(FanavaranClientConfig.name)
private readonly configModel: Model<FanavaranClientConfigDocument>,
) {}
async onModuleInit(): Promise<void> {
await this.seedMissingProfiles();
await this.reloadCache();
}
async seedMissingProfiles(): Promise<void> {
for (const key of FANAVARAN_CLIENT_KEYS) {
const seed = SEED_FANAVARAN_CLIENT_PROFILES[key];
const existing = await this.configModel
.findOne({ key })
.select({ _id: 1 })
.lean()
.exec();
if (existing) {
continue;
}
await this.configModel.create({
key: seed.key,
auth: seed.auth,
defaults: seed.defaults,
});
this.logger.log(
`Seeded fanavaranClientConfigs for client "${key}" (ClaimExpertId=${seed.defaults.ClaimExpertId}, ExpertiseClaimExpertId=${seed.defaults.ExpertiseClaimExpertId})`,
);
}
}
async reloadCache(): Promise<void> {
const docs = await this.configModel.find().lean().exec();
const cache: Partial<Record<FanavaranClientKey, FanavaranClientProfile>> =
{};
for (const key of FANAVARAN_CLIENT_KEYS) {
const doc = docs.find((row) => row.key === key);
if (doc?.auth && doc?.defaults) {
cache[key] = {
key,
auth: {
appName: doc.auth.appName,
secret: doc.auth.secret,
username: doc.auth.username,
password: doc.auth.password,
corpId: doc.auth.corpId,
contractId: doc.auth.contractId,
location: doc.auth.location,
},
defaults: { ...doc.defaults },
};
} else {
cache[key] = SEED_FANAVARAN_CLIENT_PROFILES[key];
}
}
setFanavaranClientProfilesCache(cache);
this.logger.log(
`Loaded ${docs.length} Fanavaran client config(s) from Mongo into runtime cache`,
);
}
async findByKey(key: FanavaranClientKey) {
return this.configModel.findOne({ key }).lean().exec();
}
async list() {
return this.configModel.find().lean().exec();
}
}

View File

@@ -1,8 +1,20 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config"; import { ConfigModule, ConfigService } from "@nestjs/config";
import { MongooseModule } from "@nestjs/mongoose";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { FanavaranAuditModule } from "./fanavaran-audit.module";
import { FanavaranAuthService } from "./fanavaran-auth.service";
import { FanavaranClientConfigService } from "./fanavaran-client-config.service";
import { FanavaranLookupService } from "./fanavaran-lookup.service"; import { FanavaranLookupService } from "./fanavaran-lookup.service";
import {
FanavaranAuthToken,
FanavaranAuthTokenSchema,
} from "./schema/fanavaran-auth-token.schema";
import {
FanavaranClientConfig,
FanavaranClientConfigSchema,
} from "./schema/fanavaran-client-config.schema";
@Module({ @Module({
imports: [ imports: [
@@ -11,8 +23,24 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service";
inject: [ConfigService], inject: [ConfigService],
useFactory: createHttpModuleOptions, useFactory: createHttpModuleOptions,
}), }),
MongooseModule.forFeature([
{ name: FanavaranAuthToken.name, schema: FanavaranAuthTokenSchema },
{
name: FanavaranClientConfig.name,
schema: FanavaranClientConfigSchema,
},
]),
FanavaranAuditModule,
],
providers: [
FanavaranClientConfigService,
FanavaranAuthService,
FanavaranLookupService,
],
exports: [
FanavaranClientConfigService,
FanavaranAuthService,
FanavaranLookupService,
], ],
providers: [FanavaranLookupService],
exports: [FanavaranLookupService],
}) })
export class FanavaranLookupModule {} export class FanavaranLookupModule {}

View File

@@ -9,26 +9,22 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { import { type FanavaranClientKey } from "src/core/config/fanavaran-client.config";
getFanavaranClientProfile,
type FanavaranClientKey,
} from "src/core/config/fanavaran-client.config";
import { import {
FANAVARAN_LOOKUP_BASE_URL, FANAVARAN_LOOKUP_BASE_URL,
fanavaranLookupCacheDir, fanavaranLookupCacheDir,
tejaratStaticAccidentFilePath, tejaratStaticAccidentFilePath,
} from "./fanavaran-lookup.config"; } from "./fanavaran-lookup.config";
import { FanavaranAuthService } from "./fanavaran-auth.service";
@Injectable() @Injectable()
export class FanavaranLookupService { export class FanavaranLookupService {
private readonly logger = new Logger(FanavaranLookupService.name); private readonly logger = new Logger(FanavaranLookupService.name);
private readonly getAppTokenUrl = constructor(
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken"; private readonly httpService: HttpService,
private readonly loginUrl = private readonly fanavaranAuthService: FanavaranAuthService,
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login"; ) {}
constructor(private readonly httpService: HttpService) {}
private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string { private cacheFilePath(clientKey: FanavaranClientKey, fileName: string): string {
return join(fanavaranLookupCacheDir(clientKey), fileName); return join(fanavaranLookupCacheDir(clientKey), fileName);
@@ -71,91 +67,12 @@ export class FanavaranLookupService {
return JSON.parse(cached) as T; 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( async fetchFromFanavaran(
clientKey: FanavaranClientKey, clientKey: FanavaranClientKey,
url: string, url: string,
): Promise<unknown> { ): Promise<unknown> {
const profile = getFanavaranClientProfile(clientKey);
try { try {
const appToken = await this.getAppToken(profile.auth); const headers = await this.fanavaranAuthService.getRequestHeaders(clientKey);
const authenticationToken = await this.login(appToken, profile.auth);
this.logger.log( this.logger.log(
`[${clientKey}] Calling Fanavaran lookup API: ${url}`, `[${clientKey}] Calling Fanavaran lookup API: ${url}`,
@@ -164,10 +81,7 @@ export class FanavaranLookupService {
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.get(url, { this.httpService.get(url, {
headers: { headers: {
authenticationToken, ...headers,
CorpId: profile.auth.corpId,
ContractId: profile.auth.contractId,
Location: profile.auth.location,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
timeout: 20000, timeout: 20000,
@@ -184,8 +98,10 @@ export class FanavaranLookupService {
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`, `[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
); );
this.fanavaranAuthService.clearBackoff(clientKey);
return response.data; return response.data;
} catch (error) { } catch (error) {
this.fanavaranAuthService.registerFailure(clientKey, error);
const message = isAxiosError(error) const message = isAxiosError(error)
? error.response?.data?.Message || ? error.response?.data?.Message ||
error.response?.data?.message || error.response?.data?.message ||

View File

@@ -18,6 +18,8 @@ import {
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { import {
FANAVARAN_CLIENT_KEYS,
FANAVARAN_CLIENT_SWAGGER_ENUM,
isFanavaranClientKey, isFanavaranClientKey,
listFanavaranClientProfiles, listFanavaranClientProfiles,
normalizeFanavaranClientKey, normalizeFanavaranClientKey,
@@ -37,7 +39,7 @@ export class FanavaranController {
@ApiOperation({ @ApiOperation({
summary: "List supported Fanavaran insurance clients", summary: "List supported Fanavaran insurance clients",
description: description:
"Returns configured Fanavaran tenants (parsian, tejaratno) and which client is active for this deployment.", "Returns configured Fanavaran tenants (parsian, tejaratno, moallem) and which client is active for this deployment.",
}) })
listClients() { listClients() {
const activeClient = resolveFanavaranClientKey(); const activeClient = resolveFanavaranClientKey();
@@ -55,12 +57,12 @@ export class FanavaranController {
@ApiOperation({ @ApiOperation({
summary: "Preview Fanavaran base claim create payload", summary: "Preview Fanavaran base claim create payload",
description: description:
"Builds the GEN.03 third-party-car-financial-claims payload from local claimCases + blameCases without creating a Fanavaran claim.", "Builds the GEN.03 payload from local claim/blame data. Fanavaran-sourced PolicyId is resolve-once: first call may inquire and caches on the claim; later calls reuse the cache. Auth token is shared until Asia/Tehran midnight. Pass forceRefreshPolicy=true to re-inquire.",
}) })
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -71,16 +73,39 @@ export class FanavaranController {
required: false, required: false,
description: "When true, returns payload plus mapping debug steps", description: "When true, returns payload plus mapping debug steps",
}) })
@ApiQuery({
name: "forceRefreshPolicy",
required: false,
description:
"When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again. Do not pass this from normal UI loads.",
})
@ApiQuery({
name: "resolvePolicy",
required: false,
deprecated: true,
description:
"Deprecated. Ignored for cache-busting. PolicyId is resolve-once from fanavaranSync.baseClaim.policyId; use forceRefreshPolicy=true only to re-inquire.",
})
async preview( async preview(
@Param("client") client: string, @Param("client") client: string,
@Param("claimCaseId") claimCaseId: string, @Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string, @Query("debug") debug?: string,
@Query("forceRefreshPolicy") forceRefreshPolicy?: string,
@Query("resolvePolicy") _resolvePolicy?: string,
) { ) {
const clientKey = this.parseClientParam(client); const clientKey = this.parseClientParam(client);
// IMPORTANT: resolvePolicy must NOT force a live inquiry. Older UI clients
// send resolvePolicy=true on every preview load; that used to defeat the
// PolicyId cache and re-Login Fanavaran on every click.
return await this.claimRequestManagementService.previewFanavaranSubmitV2( return await this.claimRequestManagementService.previewFanavaranSubmitV2(
claimCaseId, claimCaseId,
clientKey, clientKey,
{ debug: debug === "1" || debug === "true" }, {
debug: debug === "1" || debug === "true",
forceRefreshPolicy:
forceRefreshPolicy === "1" || forceRefreshPolicy === "true",
requirePolicyId: false,
},
); );
} }
@@ -93,7 +118,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -121,7 +146,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -142,12 +167,12 @@ export class FanavaranController {
@ApiOperation({ @ApiOperation({
summary: "Submit Fanavaran damage-case request", summary: "Submit Fanavaran damage-case request",
description: description:
"Submits the GEN.12 dmg-cases request for the already-created Fanavaran claim and stores returned Id as local dmgCaseId.", "Submits the GEN.12 dmg-cases request. If base claim (claimId) is missing, soft-ensures GEN.03 base claim first, then submits damage. Skips when dmgCaseId already exists.",
}) })
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -175,7 +200,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -201,7 +226,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -227,7 +252,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -253,7 +278,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -275,7 +300,7 @@ export class FanavaranController {
private parseClientParam(client: string) { private parseClientParam(client: string) {
if (!isFanavaranClientKey(client)) { if (!isFanavaranClientKey(client)) {
throw new BadRequestException( throw new BadRequestException(
`Invalid Fanavaran client "${client}". Expected one of: parsian, tejaratno`, `Invalid Fanavaran client "${client}". Expected one of: ${FANAVARAN_CLIENT_KEYS.join(", ")}`,
); );
} }
return normalizeFanavaranClientKey(client); return normalizeFanavaranClientKey(client);

View File

@@ -62,12 +62,33 @@ export class FanavaranAuditLog {
@Prop({ type: String, required: false }) @Prop({ type: String, required: false })
requestUrl?: string; requestUrl?: string;
@Prop({ type: String, required: false })
requestMethod?: string;
@Prop({ type: Number, required: false }) @Prop({ type: Number, required: false })
httpStatus?: number; httpStatus?: number;
/** Sanitized outbound headers (secrets masked). */
@Prop({ type: Object, required: false })
requestHeaders?: Record<string, unknown>;
/** Outbound JSON/body (truncated; secrets masked). */
@Prop({ type: Object, required: false })
requestBody?: unknown;
/** Compact structured facts (ids, flags) — kept for filtering/dashboards. */
@Prop({ type: Object, required: false }) @Prop({ type: Object, required: false })
requestMeta?: Record<string, unknown>; requestMeta?: Record<string, unknown>;
/** Sanitized inbound response headers. */
@Prop({ type: Object, required: false })
responseHeaders?: Record<string, unknown>;
/** Inbound response body (truncated). */
@Prop({ type: Object, required: false })
responseBody?: unknown;
/** Compact structured facts from the response. */
@Prop({ type: Object, required: false }) @Prop({ type: Object, required: false })
responseMeta?: Record<string, unknown>; responseMeta?: Record<string, unknown>;
@@ -86,3 +107,4 @@ export const FanavaranAuditLogSchema =
SchemaFactory.createForClass(FanavaranAuditLog); SchemaFactory.createForClass(FanavaranAuditLog);
FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 }); FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 });
FanavaranAuditLogSchema.index({ claimCaseId: 1, createdAt: 1 });

View File

@@ -0,0 +1,24 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config";
/**
* Shared Fanavaran authenticationToken per tenant.
* Survives process restarts / multi-instance so we do not Login on every request.
*/
@Schema({ collection: "fanavaranAuthTokens", timestamps: true })
export class FanavaranAuthToken {
@Prop({ type: String, required: true, unique: true, index: true })
clientKey: FanavaranClientKey;
@Prop({ type: String, required: true })
authenticationToken: string;
/** When this token should be refreshed (Asia/Tehran midnight). */
@Prop({ type: Date, required: true, index: true })
expiresAt: Date;
}
export type FanavaranAuthTokenDocument = HydratedDocument<FanavaranAuthToken>;
export const FanavaranAuthTokenSchema =
SchemaFactory.createForClass(FanavaranAuthToken);

View File

@@ -0,0 +1,111 @@
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 FanavaranAuthConfigEmbed {
@Prop({ type: String, required: true })
appName: string;
@Prop({ type: String, required: true })
secret: string;
@Prop({ type: String, required: true })
username: string;
@Prop({ type: String, required: true })
password: string;
@Prop({ type: String, required: true })
corpId: string;
@Prop({ type: String, required: true })
contractId: string;
@Prop({ type: String, required: true })
location: string;
}
export const FanavaranAuthConfigEmbedSchema = SchemaFactory.createForClass(
FanavaranAuthConfigEmbed,
);
@Schema({ _id: false })
export class FanavaranPayloadDefaultsEmbed {
@Prop({ type: Number, required: true })
AccidentCityId: number;
@Prop({ type: Number, required: true })
AccidentReportTypeId: number;
@Prop({ type: Number, required: true })
AccidentVehicleUsedId: number;
/**
* GEN.03 base claim — کارشناس مسئول پرونده مالی
* (Parsian example: 154 / 4662 — NOT the assessor role).
*/
@Prop({ type: Number, required: true })
ClaimExpertId: number;
/**
* GEN.08 expertise — کارشناس ارزیاب خسارت ثالث مالی (or وکیل/تحقیق).
* (Parsian example: 29; Tejaratno proven send: 2709).
*/
@Prop({ type: Number, required: true })
ExpertiseClaimExpertId: number;
@Prop({ type: Number, required: true })
CompensationReferenceId: number;
@Prop({ type: Number, required: true })
CulpritLicenceTypeId: number;
@Prop({ type: Number, required: true })
CulpritTypeId: number;
@Prop({ type: Number, required: true })
DmgCaseTypeId: number;
@Prop({ type: Number, required: true })
DmgHistoryStatus: number;
@Prop({ type: Number, required: true })
PlaqueKindId: number;
@Prop({ type: Number, required: true })
PlaqueSampleId: number;
@Prop({ type: Number, required: true })
DriverIsOwner: number;
@Prop({ type: Number, required: true })
FaultPercent: number;
@Prop({ type: Number, required: true })
ClaimFileTypeId: number;
}
export const FanavaranPayloadDefaultsEmbedSchema = SchemaFactory.createForClass(
FanavaranPayloadDefaultsEmbed,
);
/**
* Per-tenant Fanavaran auth + payload defaults.
* Seeded on boot when missing; edit in Mongo without redeploying code.
*/
@Schema({ collection: "fanavaranClientConfigs", timestamps: true })
export class FanavaranClientConfig {
@Prop({ type: String, required: true, unique: true, index: true })
key: FanavaranClientKey;
@Prop({ type: FanavaranAuthConfigEmbedSchema, required: true })
auth: FanavaranAuthConfigEmbed;
@Prop({ type: FanavaranPayloadDefaultsEmbedSchema, required: true })
defaults: FanavaranPayloadDefaultsEmbed;
}
export type FanavaranClientConfigDocument =
HydratedDocument<FanavaranClientConfig>;
export const FanavaranClientConfigSchema = SchemaFactory.createForClass(
FanavaranClientConfig,
);

View File

@@ -369,6 +369,7 @@ export class InquiryRefreshService {
} }
next.insurance.policyNumber = next.insurance.policyNumber =
mapped.PrntCmpDocNo ||
mapped.PrntPlcyCmpDocNo || mapped.PrntPlcyCmpDocNo ||
mapped.printNumber || mapped.printNumber ||
mapped.insuranceNumber || mapped.insuranceNumber ||
@@ -384,9 +385,17 @@ export class InquiryRefreshService {
? String(financialCeiling) ? String(financialCeiling)
: next.insurance.financialCeiling; : next.insurance.financialCeiling;
next.insurance.startDate = next.insurance.startDate =
mapped.persianStartDate || mapped.IssueDate || mapped.SatrtDate || next.insurance.startDate; mapped.StartDate ||
mapped.HBgnDte ||
mapped.persianStartDate ||
mapped.SatrtDate ||
mapped.IssueDate ||
next.insurance.startDate;
next.insurance.endDate = next.insurance.endDate =
mapped.persianEndDate || mapped.EndDate || next.insurance.endDate; mapped.EndDate ||
mapped.HEndDte ||
mapped.persianEndDate ||
next.insurance.endDate;
return next; return next;
} }

View File

@@ -1371,11 +1371,23 @@ export class RequestManagementService {
}; };
if (!party.insurance) party.insurance = {} as any; if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; party.insurance.policyNumber =
inquiryMapped?.PrntCmpDocNo ||
inquiryMapped?.PrntPlcyCmpDocNo ||
inquiryMapped?.printNumber ||
inquiryMapped?.insuranceNumber;
party.insurance.company = clientName; party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.startDate =
party.insurance.endDate = inquiryMapped?.EndDate; inquiryMapped?.StartDate ||
inquiryMapped?.HBgnDte ||
inquiryMapped?.persianStartDate ||
inquiryMapped?.SatrtDate ||
inquiryMapped?.IssueDate;
party.insurance.endDate =
inquiryMapped?.EndDate ||
inquiryMapped?.HEndDte ||
inquiryMapped?.persianEndDate;
// CAR BODY EXTERNAL API INQUIRY // CAR BODY EXTERNAL API INQUIRY
if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) { if (req.type === BlameRequestType.CAR_BODY && role === PartyRole.FIRST) {
@@ -1776,11 +1788,23 @@ export class RequestManagementService {
}; };
if (!party.insurance) party.insurance = {} as any; if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; party.insurance.policyNumber =
inquiryMapped?.PrntCmpDocNo ||
inquiryMapped?.PrntPlcyCmpDocNo ||
inquiryMapped?.printNumber ||
inquiryMapped?.insuranceNumber;
party.insurance.company = clientName; party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.startDate =
party.insurance.endDate = inquiryMapped?.EndDate; inquiryMapped?.StartDate ||
inquiryMapped?.HBgnDte ||
inquiryMapped?.persianStartDate ||
inquiryMapped?.SatrtDate ||
inquiryMapped?.IssueDate;
party.insurance.endDate =
inquiryMapped?.EndDate ||
inquiryMapped?.HEndDte ||
inquiryMapped?.persianEndDate;
// Advance workflow // Advance workflow
await this.advanceWorkflowToNext(req, stepKey); await this.advanceWorkflowToNext(req, stepKey);
@@ -8592,11 +8616,23 @@ export class RequestManagementService {
}; };
if (!party.insurance) party.insurance = {} as any; if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; party.insurance.policyNumber =
inquiryMapped?.PrntCmpDocNo ||
inquiryMapped?.PrntPlcyCmpDocNo ||
inquiryMapped?.printNumber ||
inquiryMapped?.insuranceNumber;
party.insurance.company = clientName; party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.startDate =
party.insurance.endDate = inquiryMapped?.EndDate; inquiryMapped?.StartDate ||
inquiryMapped?.HBgnDte ||
inquiryMapped?.persianStartDate ||
inquiryMapped?.SatrtDate ||
inquiryMapped?.IssueDate;
party.insurance.endDate =
inquiryMapped?.EndDate ||
inquiryMapped?.HEndDte ||
inquiryMapped?.persianEndDate;
if ( if (
req.type === BlameRequestType.CAR_BODY && req.type === BlameRequestType.CAR_BODY &&
@@ -9392,11 +9428,23 @@ export class RequestManagementService {
}; };
if (!party.insurance) party.insurance = {} as any; if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; party.insurance.policyNumber =
inquiryMapped?.PrntCmpDocNo ||
inquiryMapped?.PrntPlcyCmpDocNo ||
inquiryMapped?.printNumber ||
inquiryMapped?.insuranceNumber;
party.insurance.company = clientName; party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.startDate =
party.insurance.endDate = inquiryMapped?.EndDate; inquiryMapped?.StartDate ||
inquiryMapped?.HBgnDte ||
inquiryMapped?.persianStartDate ||
inquiryMapped?.SatrtDate ||
inquiryMapped?.IssueDate;
party.insurance.endDate =
inquiryMapped?.EndDate ||
inquiryMapped?.HEndDte ||
inquiryMapped?.persianEndDate;
if ( if (
req.type === BlameRequestType.CAR_BODY && req.type === BlameRequestType.CAR_BODY &&

View File

@@ -114,4 +114,32 @@ describe("SandHubService inquiry mocks", () => {
expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-LEGACY/200/1"); expect(mapped.PrntPlcyCmpDocNo).toBe("1405/1143-LEGACY/200/1");
}); });
it("maps ESG PrntCmpDocNo → PrntPlcyCmpDocNo and preserves begin/end dates", () => {
const mapped = (service as any).mapEsgPolicyByPlateToOldFormat({
success: true,
data: {
PrntCmpDocNo: "1110/111130/404/003412",
LastCmpDocNo: "1110/111130/403/002650",
PlcyUnqCod: "16018466143",
HBgnDte: "1404/10/23",
HEndDte: "1405/10/23",
HIsuDte: "1404/10/21",
VIN: "IRFC891V7D2301065",
MtrNum: "13389030167",
ShsNum: "NAAP41FD5BJ301065",
PrdDte: "1389",
CmpCod: "8",
CmpNam: "بيمه پارسيان",
},
});
expect(mapped.PrntPlcyCmpDocNo).toBe("1110/111130/404/003412");
expect(mapped.ThirdPolicyCode).toBe("16018466143");
expect(mapped.StartDate).toBe("1404/10/23");
expect(mapped.EndDate).toBe("1405/10/23");
expect(mapped.IssueDate).toBe("1404/10/21");
expect(mapped.VIN).toBe("IRFC891V7D2301065");
expect(mapped.ModelField).toBe("1389");
});
}); });

View File

@@ -560,14 +560,22 @@ export class SandHubService {
mtrnum: data.MtrNum ?? data.mtrnum, mtrnum: data.MtrNum ?? data.mtrnum,
shsNam: data.ShsNum ?? data.ShsNam ?? data.shsNam, shsNam: data.ShsNum ?? data.ShsNam ?? data.shsNam,
vin: data.VIN ?? data.vin, vin: data.VIN ?? data.vin,
VIN: data.VIN ?? data.vin,
ntnlId: data.NtnlId ?? data.ntnlId, ntnlId: data.NtnlId ?? data.ntnlId,
fullname: data.InsNam ?? data.fullname, fullname: data.InsNam ?? data.fullname,
ThirdPolicyCode: data.PlcyUnqCod ?? data.ThirdPolicyCode, ThirdPolicyCode: data.PlcyUnqCod ?? data.ThirdPolicyCode,
// ESG print/policy document number — canonical Tejarat name is PrntPlcyCmpDocNo
PrntPlcyCmpDocNo:
data.PrntCmpDocNo ??
data.PrntPlcyCmpDocNo ??
data.printNumber ??
data.insuranceNumber,
LastCompanyDocumentNumber: LastCompanyDocumentNumber:
data.LastCmpDocNo ?? data.LastCompanyDocumentNumber, data.LastCmpDocNo ?? data.LastCompanyDocumentNumber,
IssueDate: data.HIsuDte ?? data.IssueDate, IssueDate: data.HIsuDte ?? data.IssueDate,
StartDate: data.HBgnDte ?? data.StartDate, StartDate: data.HBgnDte ?? data.StartDate,
EndDate: data.HEndDte ?? data.EndDate, EndDate: data.HEndDte ?? data.EndDate,
ModelField: data.PrdDte ?? data.ModelField ?? data.ModelCii,
EdrsJson: data.Edrses ?? data.EdrsJson, EdrsJson: data.Edrses ?? data.EdrsJson,
}); });
} }
@@ -1010,14 +1018,26 @@ export class SandHubService {
FinancialCvrCptl: FinancialCvrCptl:
newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0", newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0",
// Dates // Dates — keep IssueDate (HIsuDte) distinct from StartDate (HBgnDte)
IssueDate: newResponse.persianStartDate || newResponse.IssueDate, IssueDate:
EndDate: newResponse.persianEndDate || newResponse.EndDate, newResponse.IssueDate ||
newResponse.HIsuDte ||
newResponse.persianStartDate,
StartDate:
newResponse.StartDate ||
newResponse.HBgnDte ||
newResponse.SatrtDate ||
newResponse.persianStartDate,
EndDate:
newResponse.EndDate ||
newResponse.HEndDte ||
newResponse.persianEndDate,
// Insurance policy number — canonical Tejarat name is PrntPlcyCmpDocNo; // Insurance policy number — canonical Tejarat name is PrntPlcyCmpDocNo;
// newer API formats surface it as printNumber or insuranceNumber. // ESG uses PrntCmpDocNo; newer formats use printNumber / insuranceNumber.
PrntPlcyCmpDocNo: PrntPlcyCmpDocNo:
newResponse.PrntPlcyCmpDocNo || newResponse.PrntPlcyCmpDocNo ||
newResponse.PrntCmpDocNo ||
newResponse.printNumber || newResponse.printNumber ||
newResponse.insuranceNumber || newResponse.insuranceNumber ||
null, null,

View File

@@ -18,6 +18,8 @@ const PARSIAN_TEMPLATE_BODIES: Record<string, string> = {
"لطفاً مدارک پرونده {token} ({token2}) را مجدداً ارسال کنید.\nلینک: {token3}", "لطفاً مدارک پرونده {token} ({token2}) را مجدداً ارسال کنید.\nلینک: {token3}",
"yara-signature": "yara-signature":
"امضای پرونده {token} ({token2}) توسط کارشناس {token3} بررسی شد.\nلینک: {token10}", "امضای پرونده {token} ({token2}) توسط کارشناس {token3} بررسی شد.\nلینک: {token10}",
"yara-fanavaran-claim":
"کاربر گرامی پرونده شما به شماره {token3} در فناوران با شناسه {token2} و شماره {token} ثبت شده است. جهت پیگیری های آتی پرونده خود باید از این اطلاعات استفاده کنید.",
}; };
function applyTokens( function applyTokens(

View File

@@ -191,6 +191,40 @@ export class SmsOrchestrationService implements OnModuleInit {
}); });
} }
/**
* Notify the claim owner (damaged party) that the Fanavaran base claim was
* created. Uses the same SMS gateway/provider as login for this deployment.
*
* token = ClaimNo, token2 = claimId (Fanavaran Id), token3 = Yara publicId
*
* Parsian body:
* کاربر گرامی پرونده شما به شماره {publicId} در فناوران با شناسه {claimId}
* و شماره {claimNo} ثبت شده است. جهت پیگیری های آتی پرونده خود باید از این
* اطلاعات استفاده کنید.
*/
async sendFanavaranBaseClaimRegisteredNotice(params: {
receptor: string;
publicId: string;
claimNo?: string | number | null;
claimId?: string | number | null;
}): Promise<boolean> {
const claimNo =
params.claimNo != null && String(params.claimNo).trim()
? String(params.claimNo)
: "-";
const claimId =
params.claimId != null && String(params.claimId).trim()
? String(params.claimId)
: "-";
return this.sendTemplate({
template: "yara-fanavaran-claim",
receptor: params.receptor,
token: params.publicId || "-",
token2: claimId,
token3: claimNo,
});
}
private async sendTemplate(args: TemplateArgs): Promise<boolean> { private async sendTemplate(args: TemplateArgs): Promise<boolean> {
try { try {
await this.smsGatewayService.verifyLookUp(args); await this.smsGatewayService.verifyLookUp(args);