update the esg

This commit is contained in:
2026-06-20 18:18:48 +03:30
parent 539e7a4060
commit b3adb6f583
17 changed files with 1086 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ import { RateLimitModule } from './rate-limit/rate-limit.module';
import { LoggingModule } from './logging/logging.module'; import { LoggingModule } from './logging/logging.module';
import { ProvidersModule } from './providers/providers.module'; import { ProvidersModule } from './providers/providers.module';
import { InquiryModule } from './inquiry/inquiry.module'; import { InquiryModule } from './inquiry/inquiry.module';
import { ClaimsModule } from './claims/claims.module';
import { DatabaseSeederModule } from './database/database-seeder.module'; import { DatabaseSeederModule } from './database/database-seeder.module';
/** /**
@@ -34,6 +35,7 @@ import { DatabaseSeederModule } from './database/database-seeder.module';
DatabaseSeederModule, DatabaseSeederModule,
ProvidersModule, ProvidersModule,
InquiryModule, InquiryModule,
ClaimsModule,
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Post, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ResponseTransformInterceptor } from '../common/interceptors/response-transform.interceptor';
import { UserRateLimitGuard } from '../rate-limit/user-rate-limit.guard';
import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto';
import { ThirdPartyClaimService, ThirdPartyClaimSubmitResult } from './third-party-claim.service';
@ApiTags('Claims')
@ApiBearerAuth()
@Controller('claims')
@UseGuards(JwtAuthGuard, UserRateLimitGuard)
@UseInterceptors(ResponseTransformInterceptor)
export class ClaimsController {
constructor(private readonly thirdPartyClaimService: ThirdPartyClaimService) {}
@Post('third-party/submit')
@Throttle({ default: { limit: 20, ttl: 60000 } })
@ApiOperation({
summary: 'Submit a third-party car financial claim to Fanavaran',
description:
'Authenticates with Fanavaran, resolves the latest third-party policy for the national code, ' +
'resolves ClaimExpertId from evaluation history when provided, and submits the claim.',
})
@ApiResponse({ status: 201, description: 'Claim submitted or structured failure returned' })
async submitThirdPartyClaim(
@Body() dto: SubmitThirdPartyClaimDto,
): Promise<ThirdPartyClaimSubmitResult> {
return this.thirdPartyClaimService.submit(dto);
}
}

View File

@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ProvidersModule } from '../providers/providers.module';
import { ClaimsController } from './claims.controller';
import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service';
import { FanavaranApiService } from './fanavaran/fanavaran-api.service';
import { EvaluationRequest, EvaluationRequestSchema } from './schemas/evaluation-request.schema';
import { FieldExpert, FieldExpertSchema } from './schemas/field-expert.schema';
import { ClaimExpertResolverService } from './services/claim-expert-resolver.service';
import { EvaluationRequestService } from './services/evaluation-request.service';
import { ThirdPartyClaimService } from './third-party-claim.service';
@Module({
imports: [
ProvidersModule,
MongooseModule.forFeature([
{ name: FieldExpert.name, schema: FieldExpertSchema },
{ name: EvaluationRequest.name, schema: EvaluationRequestSchema },
]),
],
controllers: [ClaimsController],
providers: [
FanavaranAuthService,
FanavaranApiService,
EvaluationRequestService,
ClaimExpertResolverService,
ThirdPartyClaimService,
],
exports: [ThirdPartyClaimService, FanavaranApiService, ClaimExpertResolverService],
})
export class ClaimsModule {}

View File

@@ -0,0 +1,312 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Length,
Matches,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { FanavaranInsuranceLineId } from '../fanavaran/fanavaran.types';
export class SubmitThirdPartyClaimDto {
@ApiProperty({ example: '0059132574', description: 'Policy holder national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiPropertyOptional({
example: FanavaranInsuranceLineId.THIRD_PARTY,
enum: FanavaranInsuranceLineId,
default: FanavaranInsuranceLineId.THIRD_PARTY,
})
@IsOptional()
@IsEnum(FanavaranInsuranceLineId)
@Type(() => Number)
insuranceLineId?: FanavaranInsuranceLineId;
@ApiPropertyOptional({
description: 'Override ClaimExpertId instead of resolving from evaluation history',
example: 4662,
})
@IsOptional()
@IsInt()
@Type(() => Number)
claimExpertId?: number;
@ApiPropertyOptional({
description: 'Evaluation/claim case id — latest expert from history is used when claimExpertId is omitted',
example: 'CASE-2026-001',
})
@IsOptional()
@IsString()
evaluationRequestId?: string;
@ApiPropertyOptional({
description: 'Use a specific policy id instead of resolving the latest policy from Fanavaran',
example: 14821240,
})
@IsOptional()
@IsInt()
@Type(() => Number)
policyId?: number;
@ApiProperty({ example: 701 })
@IsInt()
@Type(() => Number)
AccidentCityId!: number;
@ApiProperty({ example: 155 })
@IsInt()
@Type(() => Number)
AccidentReportTypeId!: number;
@ApiProperty({ example: 1 })
@IsInt()
@Type(() => Number)
AccidentVehicleUsedId!: number;
@ApiProperty({ example: 167 })
@IsInt()
@Type(() => Number)
CompensationReferenceId!: number;
@ApiProperty({ example: 2 })
@IsInt()
@Type(() => Number)
CulpritLicenceTypeId!: number;
@ApiProperty({ example: 337 })
@IsInt()
@Type(() => Number)
CulpritTypeId!: number;
@ApiProperty({ example: 6 })
@IsInt()
@Type(() => Number)
AccidentCauseId!: number;
@ApiProperty({ example: '1405/03/17' })
@IsString()
@IsNotEmpty()
AccidentDate!: string;
@ApiProperty({ example: '1405/03/17' })
@IsString()
@IsNotEmpty()
AnnouncementDate!: string;
@ApiProperty({ example: '1405/03/17' })
@IsString()
@IsNotEmpty()
DocReceivedDate!: string;
@ApiProperty({ example: '15:59' })
@IsString()
@IsNotEmpty()
AccidentTime!: string;
@ApiProperty({ example: 'تهران، محله کوی نصر، بزرگراه جلال آل احمد' })
@IsString()
@IsNotEmpty()
AccidentLocationAddress!: string;
@ApiProperty({ example: 143 })
@IsNumber()
@Min(0)
@Type(() => Number)
EstimateAmount!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
AccidentCulpritId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
CouponNo?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
CourtArchiveNo?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
CulpritLicenceCityId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
CulpritLicenceCountryId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
CulpritLicenceForeignCityName?: string | null;
@ApiProperty({ example: '1394/10/13' })
@IsString()
@IsNotEmpty()
CulpritLicenceIssuDate!: string;
@ApiProperty({ example: '1124242' })
@IsString()
@IsNotEmpty()
CulpritLicenceNo!: string;
@ApiProperty({ example: 1 })
@IsInt()
@Min(1)
@Type(() => Number)
DamagedCount!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
DmgAssessorFirstCreationTime?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
EntryDate?: string | null;
@ApiProperty({ example: 1 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsLicenseMatchWithVehicleKind!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
HasOtherCulprit!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsAccidentOutOfBorder!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsFatalAccident!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsPlaqueChanged!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueCityId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueKindId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueLeftNo?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueMiddleCodeId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PlaqueNo?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueRightNo?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueSampleId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueSerial?: number | null;
@ApiProperty({ example: 1 })
@IsInt()
@Type(() => Number)
PoliceOfficerId!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PoliceReportDesc?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PoliceReportSeri?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PoliceReportSerial?: string | null;
@ApiPropertyOptional({ example: '', default: '' })
@IsOptional()
@IsString()
PreviousPolicyEndDate?: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
TrackingCode?: string | null;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsLicenseReplacement!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
UnknownCulpritCauseId?: number | null;
}

View File

@@ -0,0 +1,119 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import { FanavaranConfig } from '../../config/configuration';
import { FanavaranAuthService } from './fanavaran-auth.service';
import {
FanavaranInsuranceLineId,
FanavaranPolicySummary,
ThirdPartyCarFinancialClaimPayload,
} from './fanavaran.types';
@Injectable()
export class FanavaranApiService {
private readonly logger = new Logger(FanavaranApiService.name);
private readonly httpClient: AxiosInstance;
private readonly config: FanavaranConfig;
constructor(
configService: ConfigService,
private readonly authService: FanavaranAuthService,
) {
this.config = configService.get<FanavaranConfig>('fanavaran')!;
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
},
});
}
async inquiryLatestPolicy(
nationalCode: string,
insuranceLineId: FanavaranInsuranceLineId = FanavaranInsuranceLineId.THIRD_PARTY,
): Promise<FanavaranPolicySummary> {
const authenticationToken = await this.authService.getAuthenticationToken();
const response = await this.httpClient.get<FanavaranPolicySummary[]>(
'/api/BimeApi/v2.0/common/Policies/inquiry-my-policies',
{
params: {
InsuranceLineId: insuranceLineId,
NationalCode: nationalCode,
},
headers: this.buildCommonHeaders(authenticationToken, this.config.policyInquiryLocation),
},
);
const policies = Array.isArray(response.data) ? response.data : [];
if (policies.length === 0) {
throw new Error(
`No Fanavaran policies found for nationalCode=${nationalCode}, insuranceLineId=${insuranceLineId}`,
);
}
const latest = this.pickLatestPolicy(policies);
this.logger.log(
`Fanavaran policy selected | nationalCode=${nationalCode} | policyId=${latest.PolicyId} | issuDate=${latest.IssuDate}`,
);
return latest;
}
async submitThirdPartyCarFinancialClaim(
payload: ThirdPartyCarFinancialClaimPayload,
): Promise<unknown> {
const authenticationToken = await this.authService.getAuthenticationToken();
try {
const response = await this.httpClient.post(
'/api/BimeApi/v2.0/car/third-party-car-financial-claims',
payload,
{
headers: this.buildCommonHeaders(authenticationToken, this.config.claimSubmitLocation),
},
);
return response.data;
} catch (error) {
throw this.formatApiError(error);
}
}
pickLatestPolicy(policies: FanavaranPolicySummary[]): FanavaranPolicySummary {
return [...policies].sort((left, right) => {
const dateCompare = right.IssuDate.localeCompare(left.IssuDate);
if (dateCompare !== 0) {
return dateCompare;
}
return right.PolicyId - left.PolicyId;
})[0];
}
private buildCommonHeaders(
authenticationToken: string,
location: number,
): Record<string, string | number> {
return {
authenticationToken,
CorpId: this.config.corpId,
ContractId: this.config.contractId,
Location: location,
};
}
private formatApiError(error: unknown): Error {
if (!(error instanceof AxiosError)) {
return error instanceof Error ? error : new Error(String(error));
}
const status = error.response?.status;
const body =
typeof error.response?.data === 'string'
? error.response.data
: JSON.stringify(error.response?.data ?? {});
return new Error(
`Fanavaran API request failed${status ? ` (${status})` : ''}: ${error.message} | body=${body}`,
);
}
}

View File

@@ -0,0 +1,141 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import { FanavaranConfig } from '../../config/configuration';
import { GeneralTokenService } from '../../providers/services/general-token.service';
import { FanavaranLoginUserInfo } from './fanavaran.types';
const APP_TOKEN_PROVIDER = 'Fanavaran:AppToken';
const AUTH_TOKEN_PROVIDER = 'Fanavaran:AuthToken';
@Injectable()
export class FanavaranAuthService {
private readonly logger = new Logger(FanavaranAuthService.name);
private readonly httpClient: AxiosInstance;
private readonly config: FanavaranConfig;
constructor(
configService: ConfigService,
private readonly generalTokenService: GeneralTokenService,
) {
this.config = configService.get<FanavaranConfig>('fanavaran')!;
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
});
}
isEnabled(): boolean {
return (
this.config.enabled &&
Boolean(this.config.baseUrl) &&
Boolean(this.config.appName) &&
Boolean(this.config.appSecret) &&
Boolean(this.config.userName) &&
Boolean(this.config.password)
);
}
async getAuthenticationToken(): Promise<string> {
const cached = await this.generalTokenService.getLatestToken(AUTH_TOKEN_PROVIDER);
if (cached && !(await this.generalTokenService.isTokenExpired(cached))) {
return cached.accessToken;
}
const appToken = await this.getAppToken();
const { authenticationToken } = await this.login(appToken);
return authenticationToken;
}
private async getAppToken(): Promise<string> {
const cached = await this.generalTokenService.getLatestToken(APP_TOKEN_PROVIDER);
if (cached && !(await this.generalTokenService.isTokenExpired(cached))) {
return cached.accessToken;
}
const response = await this.httpClient.post(
'/api/EITAuthentication/GetAppToken',
undefined,
{
headers: {
appname: this.config.appName,
secret: this.config.appSecret,
},
},
);
const appToken = this.readHeader(response.headers, 'apptoken');
if (!appToken) {
throw new Error('Fanavaran GetAppToken did not return appToken header');
}
await this.cacheToken(APP_TOKEN_PROVIDER, appToken, this.config.appTokenTtlSeconds);
return appToken;
}
private async login(appToken: string): Promise<{
authenticationToken: string;
user: FanavaranLoginUserInfo;
}> {
const response = await this.httpClient.post('/api/EITAuthentication/Login', undefined, {
headers: {
appToken,
userName: this.config.userName,
password: this.config.password,
},
});
const authenticationToken = this.readHeader(response.headers, 'authenticationtoken');
if (!authenticationToken) {
throw new Error('Fanavaran Login did not return authenticationToken header');
}
await this.cacheToken(
AUTH_TOKEN_PROVIDER,
authenticationToken,
this.config.authTokenTtlSeconds,
);
const user = response.data as FanavaranLoginUserInfo;
this.logger.log(
`Fanavaran login succeeded | user=${user?.UserName ?? this.config.userName}`,
);
return { authenticationToken, user };
}
private async cacheToken(
serviceProvider: string,
token: string,
ttlSeconds: number,
): Promise<void> {
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await this.generalTokenService.create({
serviceProvider,
tokenType: 'Bearer',
url: this.config.baseUrl,
clientId: this.config.appName,
clientSecret: '***',
username: this.config.userName,
scope: serviceProvider,
accessToken: token,
expiresIn: ttlSeconds,
expiresAt,
});
}
private readHeader(
headers: Record<string, unknown>,
name: string,
): string | undefined {
const value = headers[name];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0].trim();
}
return undefined;
}
}

View File

@@ -0,0 +1,81 @@
/** Fanavaran (Iran EIT) insurance line identifiers. */
export enum FanavaranInsuranceLineId {
CAR_BODY = 4,
THIRD_PARTY = 5,
}
export interface FanavaranPolicySummary {
BeginDate: string;
CustomerId: number;
EffectiveDate: string | null;
EndDate: string;
EndoNo: number;
InsuranceLineId: number;
IsClosed: number;
IssuDate: string;
OpUnitId: number;
PayerCustomerId: number;
PersonRoleId: number;
PolicyGroupId: number;
PolicyId: number;
PolicyNo: number;
PolicyVerNo: number;
Status: number;
TranTypeId: number;
}
export interface FanavaranLoginUserInfo {
UserName: string;
UserId: string;
FarsiName: string | null;
}
export interface ThirdPartyCarFinancialClaimPayload {
AccidentCityId: number;
AccidentReportTypeId: number;
AccidentVehicleUsedId: number;
ClaimExpertId: number;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;
AccidentCauseId: number;
AccidentDate: string;
AnnouncementDate: string;
DocReceivedDate: string;
AccidentTime: string;
AccidentLocationAddress: string;
EstimateAmount: number;
AccidentCulpritId: number | null;
CouponNo: string | null;
CourtArchiveNo: string | null;
CulpritLicenceCityId: number | null;
CulpritLicenceCountryId: number | null;
CulpritLicenceForeignCityName: string | null;
CulpritLicenceIssuDate: string;
CulpritLicenceNo: string;
DamagedCount: number;
DmgAssessorFirstCreationTime: string | null;
EntryDate: string | null;
IsLicenseMatchWithVehicleKind: number;
HasOtherCulprit: number;
IsAccidentOutOfBorder: number;
IsFatalAccident: number;
IsPlaqueChanged: number;
PlaqueCityId: number | null;
PlaqueKindId: number | null;
PlaqueLeftNo: number | null;
PlaqueMiddleCodeId: number | null;
PlaqueNo: string | null;
PlaqueRightNo: number | null;
PlaqueSampleId: number | null;
PlaqueSerial: number | null;
PoliceOfficerId: number;
PoliceReportDesc: string | null;
PoliceReportSeri: string | null;
PoliceReportSerial: string | null;
PolicyId: number;
PreviousPolicyEndDate: string;
TrackingCode: string | null;
IsLicenseReplacement: number;
UnknownCulpritCauseId: number | null;
}

View File

@@ -0,0 +1,30 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type EvaluationRequestDocument = HydratedDocument<EvaluationRequest>;
@Schema({ _id: false })
export class EvaluationHistoryEntry {
@Prop({ type: Types.ObjectId, ref: 'FieldExpert', required: true })
expertId!: Types.ObjectId;
@Prop()
expertCode?: number;
@Prop({ required: true, default: () => new Date() })
evaluatedAt!: Date;
}
export const EvaluationHistoryEntrySchema =
SchemaFactory.createForClass(EvaluationHistoryEntry);
@Schema({ timestamps: true, collection: 'evaluation_requests' })
export class EvaluationRequest {
@Prop({ required: true, unique: true, trim: true })
requestId!: string;
@Prop({ type: [EvaluationHistoryEntrySchema], default: [] })
evaluations!: EvaluationHistoryEntry[];
}
export const EvaluationRequestSchema = SchemaFactory.createForClass(EvaluationRequest);

View File

@@ -0,0 +1,18 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type FieldExpertDocument = HydratedDocument<FieldExpert>;
@Schema({ timestamps: true, collection: 'field_experts' })
export class FieldExpert {
@Prop({ required: true, trim: true })
name!: string;
@Prop({ required: true, unique: true })
expertCode!: number;
@Prop({ default: true })
active!: boolean;
}
export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert);

View File

@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { ConfigService } from '@nestjs/config';
import { FanavaranConfig } from '../../config/configuration';
import { FieldExpert, FieldExpertDocument } from '../schemas/field-expert.schema';
import { EvaluationRequestService } from './evaluation-request.service';
export interface ClaimExpertResolutionInput {
claimExpertId?: number;
evaluationRequestId?: string;
}
@Injectable()
export class ClaimExpertResolverService {
private readonly config: FanavaranConfig;
constructor(
configService: ConfigService,
@InjectModel(FieldExpert.name)
private readonly fieldExpertModel: Model<FieldExpertDocument>,
private readonly evaluationRequestService: EvaluationRequestService,
) {
this.config = configService.get<FanavaranConfig>('fanavaran')!;
}
async resolveClaimExpertId(input: ClaimExpertResolutionInput): Promise<number> {
if (input.claimExpertId !== undefined && input.claimExpertId !== null) {
return input.claimExpertId;
}
if (input.evaluationRequestId) {
const resolved = await this.resolveFromEvaluationHistory(input.evaluationRequestId);
if (resolved !== undefined) {
return resolved;
}
}
return this.config.defaultClaimExpertId;
}
private async resolveFromEvaluationHistory(
evaluationRequestId: string,
): Promise<number | undefined> {
const evaluationRequest =
await this.evaluationRequestService.findByRequestId(evaluationRequestId);
if (!evaluationRequest?.evaluations?.length) {
return undefined;
}
const latestEvaluation = [...evaluationRequest.evaluations].sort(
(left, right) => right.evaluatedAt.getTime() - left.evaluatedAt.getTime(),
)[0];
if (latestEvaluation.expertCode !== undefined && latestEvaluation.expertCode !== null) {
return latestEvaluation.expertCode;
}
if (!latestEvaluation.expertId) {
return undefined;
}
const expert = await this.fieldExpertModel
.findOne({
_id: new Types.ObjectId(latestEvaluation.expertId),
active: true,
})
.exec();
return expert?.expertCode;
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import {
EvaluationRequest,
EvaluationRequestDocument,
} from '../schemas/evaluation-request.schema';
@Injectable()
export class EvaluationRequestService {
constructor(
@InjectModel(EvaluationRequest.name)
private readonly evaluationRequestModel: Model<EvaluationRequestDocument>,
) {}
async findByRequestId(requestId: string): Promise<EvaluationRequestDocument | null> {
return this.evaluationRequestModel.findOne({ requestId }).exec();
}
}

View File

@@ -0,0 +1,159 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto';
import { FanavaranApiService } from './fanavaran/fanavaran-api.service';
import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service';
import {
FanavaranInsuranceLineId,
FanavaranPolicySummary,
ThirdPartyCarFinancialClaimPayload,
} from './fanavaran/fanavaran.types';
import { ClaimExpertResolverService } from './services/claim-expert-resolver.service';
export interface ThirdPartyClaimSubmitResult {
success: boolean;
trackingCode: string;
message: string;
duration: number;
data: {
policyId: number;
claimExpertId: number;
policy: FanavaranPolicySummary;
fanavaranResponse: unknown;
} | null;
error: { code: string; message: string } | null;
}
@Injectable()
export class ThirdPartyClaimService {
private readonly logger = new Logger(ThirdPartyClaimService.name);
constructor(
private readonly fanavaranAuthService: FanavaranAuthService,
private readonly fanavaranApiService: FanavaranApiService,
private readonly claimExpertResolver: ClaimExpertResolverService,
) {}
async submit(dto: SubmitThirdPartyClaimDto): Promise<ThirdPartyClaimSubmitResult> {
const trackingCode = generateTrackingCode();
const start = Date.now();
if (!this.fanavaranAuthService.isEnabled()) {
throw new ServiceUnavailableException('Fanavaran integration is not configured');
}
try {
const insuranceLineId =
dto.insuranceLineId ?? FanavaranInsuranceLineId.THIRD_PARTY;
const claimExpertId = await this.claimExpertResolver.resolveClaimExpertId({
claimExpertId: dto.claimExpertId,
evaluationRequestId: dto.evaluationRequestId,
});
const policy =
dto.policyId !== undefined
? ({ PolicyId: dto.policyId } as FanavaranPolicySummary)
: await this.fanavaranApiService.inquiryLatestPolicy(
dto.nationalCode,
insuranceLineId,
);
const payload = this.buildClaimPayload(dto, policy.PolicyId, claimExpertId);
const fanavaranResponse =
await this.fanavaranApiService.submitThirdPartyCarFinancialClaim(payload);
const duration = Date.now() - start;
this.logger.log(
`Third-party claim submitted | trackingCode=${trackingCode} | policyId=${policy.PolicyId} | claimExpertId=${claimExpertId} | durationMs=${duration}`,
);
return {
success: true,
trackingCode,
message: 'Third-party claim submitted successfully',
duration,
data: {
policyId: policy.PolicyId,
claimExpertId,
policy,
fanavaranResponse,
},
error: null,
};
} catch (error) {
const duration = Date.now() - start;
const message = error instanceof Error ? error.message : 'Third-party claim submission failed';
this.logger.error(
`Third-party claim failed | trackingCode=${trackingCode} | durationMs=${duration} | ${message}`,
);
return {
success: false,
trackingCode,
message,
duration,
data: null,
error: {
code: 'THIRD_PARTY_CLAIM_FAILED',
message,
},
};
}
}
private buildClaimPayload(
dto: SubmitThirdPartyClaimDto,
policyId: number,
claimExpertId: number,
): ThirdPartyCarFinancialClaimPayload {
return {
AccidentCityId: dto.AccidentCityId,
AccidentReportTypeId: dto.AccidentReportTypeId,
AccidentVehicleUsedId: dto.AccidentVehicleUsedId,
ClaimExpertId: claimExpertId,
CompensationReferenceId: dto.CompensationReferenceId,
CulpritLicenceTypeId: dto.CulpritLicenceTypeId,
CulpritTypeId: dto.CulpritTypeId,
AccidentCauseId: dto.AccidentCauseId,
AccidentDate: dto.AccidentDate,
AnnouncementDate: dto.AnnouncementDate,
DocReceivedDate: dto.DocReceivedDate,
AccidentTime: dto.AccidentTime,
AccidentLocationAddress: dto.AccidentLocationAddress,
EstimateAmount: dto.EstimateAmount,
AccidentCulpritId: dto.AccidentCulpritId ?? null,
CouponNo: dto.CouponNo ?? null,
CourtArchiveNo: dto.CourtArchiveNo ?? null,
CulpritLicenceCityId: dto.CulpritLicenceCityId ?? null,
CulpritLicenceCountryId: dto.CulpritLicenceCountryId ?? null,
CulpritLicenceForeignCityName: dto.CulpritLicenceForeignCityName ?? null,
CulpritLicenceIssuDate: dto.CulpritLicenceIssuDate,
CulpritLicenceNo: dto.CulpritLicenceNo,
DamagedCount: dto.DamagedCount,
DmgAssessorFirstCreationTime: dto.DmgAssessorFirstCreationTime ?? null,
EntryDate: dto.EntryDate ?? null,
IsLicenseMatchWithVehicleKind: dto.IsLicenseMatchWithVehicleKind,
HasOtherCulprit: dto.HasOtherCulprit,
IsAccidentOutOfBorder: dto.IsAccidentOutOfBorder,
IsFatalAccident: dto.IsFatalAccident,
IsPlaqueChanged: dto.IsPlaqueChanged,
PlaqueCityId: dto.PlaqueCityId ?? null,
PlaqueKindId: dto.PlaqueKindId ?? null,
PlaqueLeftNo: dto.PlaqueLeftNo ?? null,
PlaqueMiddleCodeId: dto.PlaqueMiddleCodeId ?? null,
PlaqueNo: dto.PlaqueNo ?? null,
PlaqueRightNo: dto.PlaqueRightNo ?? null,
PlaqueSampleId: dto.PlaqueSampleId ?? null,
PlaqueSerial: dto.PlaqueSerial ?? null,
PoliceOfficerId: dto.PoliceOfficerId,
PoliceReportDesc: dto.PoliceReportDesc ?? null,
PoliceReportSeri: dto.PoliceReportSeri ?? null,
PoliceReportSerial: dto.PoliceReportSerial ?? null,
PolicyId: policyId,
PreviousPolicyEndDate: dto.PreviousPolicyEndDate ?? '',
TrackingCode: dto.TrackingCode ?? null,
IsLicenseReplacement: dto.IsLicenseReplacement,
UnknownCulpritCauseId: dto.UnknownCulpritCauseId ?? null,
};
}
}

View File

@@ -28,4 +28,10 @@ export class NormalizedErrorDto {
description: 'Field-level validation errors when code is VALIDATION_ERROR', description: 'Field-level validation errors when code is VALIDATION_ERROR',
}) })
details?: Array<{ field: string; constraints: string[] }>; details?: Array<{ field: string; constraints: string[] }>;
@ApiPropertyOptional({
example: { nationalCode: '4311402422', NtnlId: '0015790231' },
description: 'Conflicting values when submitted data does not match provider result',
})
conflict?: Record<string, string>;
} }

View File

@@ -27,6 +27,23 @@ export interface AmitisAuthServiceConfig {
enabled: boolean; enabled: boolean;
} }
export interface FanavaranConfig {
enabled: boolean;
baseUrl: string;
timeout: number;
appName: string;
appSecret: string;
userName: string;
password: string;
corpId: number;
contractId: number;
policyInquiryLocation: number;
claimSubmitLocation: number;
defaultClaimExpertId: number;
appTokenTtlSeconds: number;
authTokenTtlSeconds: number;
}
export interface TejaratNouConfig { export interface TejaratNouConfig {
enabled: boolean; enabled: boolean;
timeout: number; timeout: number;
@@ -81,6 +98,24 @@ export default () => ({
hamta: buildProviderConfig('HAMTA'), hamta: buildProviderConfig('HAMTA'),
moallem: buildProviderConfig('MOALLEM'), moallem: buildProviderConfig('MOALLEM'),
parsian: buildProviderConfig('PARSIAN'), parsian: buildProviderConfig('PARSIAN'),
fanavaran: {
enabled: process.env.FANAVARAN_ENABLED !== 'false',
baseUrl:
process.env.FANAVARAN_BASE_URL ??
'https://apimanager.iraneit.com/BimeApiManager',
timeout: parseInt(process.env.FANAVARAN_TIMEOUT ?? '30000', 10),
appName: process.env.FANAVARAN_APP_NAME ?? '',
appSecret: process.env.FANAVARAN_APP_SECRET ?? '',
userName: process.env.FANAVARAN_USERNAME ?? '',
password: process.env.FANAVARAN_PASSWORD ?? '',
corpId: parseInt(process.env.FANAVARAN_CORP_ID ?? '543', 10),
contractId: parseInt(process.env.FANAVARAN_CONTRACT_ID ?? '28', 10),
policyInquiryLocation: parseInt(process.env.FANAVARAN_POLICY_INQUIRY_LOCATION ?? '100100', 10),
claimSubmitLocation: parseInt(process.env.FANAVARAN_CLAIM_SUBMIT_LOCATION ?? '210050', 10),
defaultClaimExpertId: parseInt(process.env.FANAVARAN_DEFAULT_CLAIM_EXPERT_ID ?? '4662', 10),
appTokenTtlSeconds: parseInt(process.env.FANAVARAN_APP_TOKEN_TTL_SECONDS ?? '3600', 10),
authTokenTtlSeconds: parseInt(process.env.FANAVARAN_AUTH_TOKEN_TTL_SECONDS ?? '3600', 10),
} satisfies FanavaranConfig,
tejaratnou: { tejaratnou: {
enabled: process.env.TEJARATNOU_ENABLED !== 'false', enabled: process.env.TEJARATNOU_ENABLED !== 'false',
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),

View File

@@ -2,6 +2,11 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator'; import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class PolicyByPlateRequestDto { export class PolicyByPlateRequestDto {
@ApiProperty({ example: '4311402422', description: 'Owner national code (10 digits)' })
@IsString()
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '12', description: 'Two digits on the left side of the plate' }) @ApiProperty({ example: '12', description: 'Two digits on the left side of the plate' })
@IsString() @IsString()
@Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' }) @Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' })

View File

@@ -128,6 +128,7 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
providerCode: partial.providerCode, providerCode: partial.providerCode,
providerTrackingCode: partial.providerTrackingCode, providerTrackingCode: partial.providerTrackingCode,
details: partial.details, details: partial.details,
conflict: partial.conflict,
}; };
} }
@@ -135,7 +136,7 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
providerMessage?: string, providerMessage?: string,
providerCode?: string, providerCode?: string,
fallbackMessage = 'Provider request failed', fallbackMessage = 'Provider request failed',
extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details'>, extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details' | 'conflict'>,
): Error { ): Error {
const message = providerMessage?.trim() || fallbackMessage; const message = providerMessage?.trim() || fallbackMessage;
const code = providerCode?.trim() || 'PROVIDER_ERROR'; const code = providerCode?.trim() || 'PROVIDER_ERROR';

View File

@@ -81,6 +81,7 @@ interface ParsianPolicyByNationalCodePayload extends LegacyInquiryPayload {
} }
interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload { interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
nationalCode?: string;
plk1?: string; plk1?: string;
plk2?: string; plk2?: string;
plk3?: string; plk3?: string;
@@ -334,6 +335,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
private async inquirePolicyByPlate( private async inquirePolicyByPlate(
payload: ParsianPolicyByPlatePayload, payload: ParsianPolicyByPlatePayload,
): Promise<{ raw: unknown }> { ): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
const plk1 = this.getRequiredString(payload.plk1, 'plk1'); const plk1 = this.getRequiredString(payload.plk1, 'plk1');
const plk2 = this.getRequiredString(payload.plk2, 'plk2'); const plk2 = this.getRequiredString(payload.plk2, 'plk2');
const plk3 = this.getRequiredString(payload.plk3, 'plk3'); const plk3 = this.getRequiredString(payload.plk3, 'plk3');
@@ -344,8 +346,24 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, PlkSrl: plksrl }, { Plk1: plk1, Plk2: plk2, Plk3: plk3, PlkSrl: plksrl },
); );
const policyNationalId = policy.NtnlId?.trim() ?? '';
if (!policyNationalId || !this.nationalIdsMatch(nationalCode, policyNationalId)) {
throw this.formatProviderError(
'عدم تطابق اطلاعات',
'INQUIRY_NO_MATCH',
undefined,
{
conflict: {
nationalCode,
NtnlId: policyNationalId,
},
},
);
}
return { return {
raw: { raw: {
nationalCode,
plk1, plk1,
plk2, plk2,
plk3, plk3,
@@ -355,6 +373,10 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
}; };
} }
private nationalIdsMatch(requested: string, fromPolicy: string): boolean {
return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0');
}
private async callCarPolicySoap( private async callCarPolicySoap(
inquiryType: InquiryType, inquiryType: InquiryType,
methodName: string, methodName: string,