From 5c2b66060039da5f0f7cb58eda831f03ad493faa Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Wed, 2 Sep 2026 15:31:55 +0330 Subject: [PATCH] locations dynamically in fanavaran --- .../claim-request-management.service.ts | 71 ++++++++- src/expert-claim/expert-claim.module.ts | 2 + src/expert-claim/expert-claim.service.ts | 8 + .../dto/create-insurer-expert.dto.ts | 46 +++++- src/expert-insurer/expert-insurer.service.ts | 17 +++ src/fanavaran/fanavaran-auth.service.ts | 5 +- src/fanavaran/fanavaran-location.service.ts | 139 ++++++++++++++++++ src/fanavaran/fanavaran-lookup.module.ts | 5 + .../request-management.module.ts | 2 + .../request-management.service.ts | 16 ++ 10 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 src/fanavaran/fanavaran-location.service.ts diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 025665c..bddf422 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -102,6 +102,7 @@ import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; +import { FanavaranLocationService } from "src/fanavaran/fanavaran-location.service"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { @@ -393,6 +394,7 @@ export class ClaimRequestManagementService { private readonly fileMakerDbService: FileMakerDbService, private readonly fieldExpertDbService: FieldExpertDbService, private readonly plateNormalizer: PlateNormalizerService, + private readonly fanavaranLocationService: FanavaranLocationService, ) {} private requiredDocumentKeysV2(isCarBody: boolean): string[] { @@ -4365,12 +4367,63 @@ export class ClaimRequestManagementService { private async getFanavaranAuthHeaders( clientKey: FanavaranClientKey, auditSession?: FanavaranAuditSession, + claimCaseId?: string, ) { + const caseId = claimCaseId ?? auditSession?.claimCaseId; + const locationOverride = caseId + ? await this.resolveFanavaranLocationForClaim(caseId, clientKey) + : undefined; return this.fanavaranAuthService.getRequestHeaders(clientKey, { auditSession, + locationOverride, }); } + /** + * Parsian + V4/V5 FileMaker flows: Location from file-maker.locations[].id, + * else file-reviewer.locations[].id, else tenant auth.location. + */ + private async resolveFanavaranLocationForClaim( + claimCaseId: string, + clientKey: FanavaranClientKey, + ): Promise { + const defaultLocation = getFanavaranClientProfile(clientKey).auth.location; + if (clientKey !== "parsian") { + return defaultLocation; + } + + try { + const claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.blameRequestId) { + return defaultLocation; + } + + const blame = await this.blameRequestDbService.findById( + String(claimCase.blameRequestId), + ); + if (!(blame as any)?.isMadeByFileMaker) { + return defaultLocation; + } + + return this.fanavaranLocationService.resolveBusinessLocation({ + clientKey, + isMadeByFileMaker: true, + fileMakerId: (blame as any)?.initiatedByFieldExpertId + ? String((blame as any).initiatedByFieldExpertId) + : null, + fileReviewerId: (blame as any)?.assignedFileReviewerId + ? String((blame as any).assignedFileReviewerId) + : null, + }); + } catch (error) { + this.logger.warn( + `[Fanavaran Location] Failed to resolve for claimCaseId=${claimCaseId}; using tenant default`, + error, + ); + return defaultLocation; + } + } + private async getPolicyIdFromNationalCode( nationalCodeOfInsurer: string, config: { @@ -4417,6 +4470,7 @@ export class ClaimRequestManagementService { const headers = await this.getFanavaranAuthHeaders( clientKey, auditSession, + options?.claimCaseId, ); const requestHeaders = { ...headers, @@ -5220,6 +5274,8 @@ export class ClaimRequestManagementService { content, [{ path: candidate.path, fileName: candidate.fileName }], clientKey, + undefined, + claimCaseId, ); this.logger.log( `${logPrefix} [${i + 1}/${pending.length}] SUCCESS: status=${response.status} body=${JSON.stringify(response.data)}`, @@ -6276,9 +6332,14 @@ export class ClaimRequestManagementService { payload: Record, clientKey: FanavaranClientKey, auditSession?: FanavaranAuditSession, + claimCaseId?: string, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); - const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession); + const headers = await this.getFanavaranAuthHeaders( + clientKey, + auditSession, + claimCaseId, + ); try { const response = await firstValueFrom( @@ -6303,9 +6364,14 @@ export class ClaimRequestManagementService { files: Array<{ path: string; fileName: string }>, clientKey: FanavaranClientKey, auditSession?: FanavaranAuditSession, + claimCaseId?: string, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); - const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession); + const headers = await this.getFanavaranAuthHeaders( + clientKey, + auditSession, + claimCaseId, + ); const form = new FormData(); form.append("Param", JSON.stringify(content), { @@ -7242,6 +7308,7 @@ export class ClaimRequestManagementService { const headers = await this.getFanavaranAuthHeaders( clientKey, auditSession, + claimCaseId, ); const requestHeaders = { diff --git a/src/expert-claim/expert-claim.module.ts b/src/expert-claim/expert-claim.module.ts index c80b18a..cc267a1 100644 --- a/src/expert-claim/expert-claim.module.ts +++ b/src/expert-claim/expert-claim.module.ts @@ -17,6 +17,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { UsersModule } from "src/users/users.module"; +import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { ExpertClaimController } from "./expert-claim.controller"; import { ExpertClaimV2Controller } from "./expert-claim.v2.controller"; import { ExpertClaimService } from "./expert-claim.service"; @@ -41,6 +42,7 @@ import { ExpertClaimService } from "./expert-claim.service"; SmsOrchestrationModule, UsersModule, ClientModule, + FanavaranLookupModule, ], controllers: [ExpertClaimController, ExpertClaimV2Controller], providers: [ExpertClaimService, ClaimFactorsImageDbService], diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 61e5ba4..31afa10 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -25,6 +25,7 @@ import { FanavaranAutoSubmitResult, FanavaranExpertiseSubmitResult, } from "src/claim-request-management/claim-request-management.service"; +import { FanavaranLocationService } from "src/fanavaran/fanavaran-location.service"; import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service"; import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service"; import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service"; @@ -291,6 +292,7 @@ export class ExpertClaimService { private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly claimSignDbService: ClaimSignDbService, private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly fanavaranLocationService: FanavaranLocationService, ) {} private appendFanavaranAutoSubmitToMessage( @@ -2590,6 +2592,12 @@ export class ExpertClaimService { // Atomically claim it — findOneAndUpdate with null/missing guard const reviewerOid = new Types.ObjectId(actor.sub); + await this.fanavaranLocationService.assertMakerReviewerLocationCompatible({ + fileMakerId: (blame as any)?.initiatedByFieldExpertId + ? String((blame as any).initiatedByFieldExpertId) + : null, + fileReviewerId: actor.sub, + }); const updated = await this.blameRequestDbService.findOneAndUpdate( { _id: (blame as any)._id, diff --git a/src/expert-insurer/dto/create-insurer-expert.dto.ts b/src/expert-insurer/dto/create-insurer-expert.dto.ts index 05e668b..c69678d 100644 --- a/src/expert-insurer/dto/create-insurer-expert.dto.ts +++ b/src/expert-insurer/dto/create-insurer-expert.dto.ts @@ -1,8 +1,30 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsEmail, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { Type } from "class-transformer"; +import { + IsArray, + IsEmail, + IsEnum, + IsMongoId, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from "class-validator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { UserType } from "src/Types&Enums/userType.enum"; +export class ExpertLocationDto { + @ApiProperty({ example: "210050", description: "Fanavaran Location / OpBUId" }) + @IsString() + @IsNotEmpty() + id: string; + + @ApiProperty({ example: "شعبه غرب" }) + @IsString() + @IsNotEmpty() + name: string; +} + export class CreateInsurerExpertDto { @ApiProperty({ example: "Ali" }) @IsString() @@ -88,6 +110,17 @@ export class CreateFileMakerByInsurerDto extends CreateInsurerExpertDto { default: RoleEnum.FILE_MAKER, }) role?: RoleEnum.FILE_MAKER; + + @ApiPropertyOptional({ + type: [ExpertLocationDto], + description: + "Fanavaran Location entries (id = OpBUId). Used for Parsian V4/V5 submits.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ExpertLocationDto) + locations?: ExpertLocationDto[]; } export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto { @@ -96,4 +129,15 @@ export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto { default: RoleEnum.FILE_REVIEWER, }) role?: RoleEnum.FILE_REVIEWER; + + @ApiPropertyOptional({ + type: [ExpertLocationDto], + description: + "Fanavaran Location entries. Must overlap FileMaker locations on Parsian V4/V5 cases.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ExpertLocationDto) + locations?: ExpertLocationDto[]; } diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 4b5b532..c5183da 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -2018,6 +2018,19 @@ export class ExpertInsurerService { return rest; } + private normalizeExpertLocations( + locations?: Array<{ id?: string; name?: string }> | null, + ): Array<{ id: string; name: string }> | undefined { + if (!Array.isArray(locations) || locations.length === 0) return undefined; + const normalized = locations + .map((entry) => ({ + id: String(entry?.id ?? "").trim(), + name: String(entry?.name ?? "").trim(), + })) + .filter((entry) => entry.id && entry.name); + return normalized.length > 0 ? normalized : undefined; + } + private async createExpertForInsurer( insurerClientKey: string, payload: CreateInsurerExpertDto, @@ -2132,6 +2145,7 @@ export class ExpertInsurerService { } const hashedPassword = await this.hashService.hash(payload.password); + const locations = this.normalizeExpertLocations(payload.locations); const created = await this.fileMakerDbService.create({ ...payload, email, @@ -2140,6 +2154,7 @@ export class ExpertInsurerService { role: RoleEnum.FILE_MAKER, clientKey: clientObjectId, branchId: new Types.ObjectId(payload.branchId), + ...(locations ? { locations } : {}), }); return { @@ -2184,6 +2199,7 @@ export class ExpertInsurerService { } const hashedPassword = await this.hashService.hash(payload.password); + const locations = this.normalizeExpertLocations(payload.locations); const created = await this.fileReviewerDbService.create({ ...payload, email, @@ -2192,6 +2208,7 @@ export class ExpertInsurerService { role: RoleEnum.FILE_REVIEWER, clientKey: clientObjectId, branchId: new Types.ObjectId(payload.branchId), + ...(locations ? { locations } : {}), }); return { diff --git a/src/fanavaran/fanavaran-auth.service.ts b/src/fanavaran/fanavaran-auth.service.ts index 6f63eab..ef45f63 100644 --- a/src/fanavaran/fanavaran-auth.service.ts +++ b/src/fanavaran/fanavaran-auth.service.ts @@ -249,6 +249,8 @@ export class FanavaranAuthService { options?: { auditSession?: FanavaranAuditSession; forceRefresh?: boolean; + /** Per-request Location override (does not affect token fingerprint). */ + locationOverride?: string; }, ): Promise<{ authenticationToken: string; @@ -261,11 +263,12 @@ export class FanavaranAuthService { clientKey, options, ); + const locationOverride = options?.locationOverride?.trim(); return { authenticationToken, CorpId: profile.auth.corpId, ContractId: profile.auth.contractId, - Location: profile.auth.location, + Location: locationOverride || profile.auth.location, }; } diff --git a/src/fanavaran/fanavaran-location.service.ts b/src/fanavaran/fanavaran-location.service.ts new file mode 100644 index 0000000..5f0352e --- /dev/null +++ b/src/fanavaran/fanavaran-location.service.ts @@ -0,0 +1,139 @@ +import { ForbiddenException, Injectable, Logger } from "@nestjs/common"; +import { + FanavaranClientKey, + getFanavaranClientProfile, + resolveFanavaranClientKey, +} from "src/core/config/fanavaran-client.config"; +import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service"; +import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; + +type LocationEntry = { id?: string; name?: string }; + +/** + * Parsian V4/V5 FileMaker flows: resolve Fanavaran HTTP `Location` from + * file-maker / file-reviewer `locations[].id`, and enforce maker/reviewer match. + */ +@Injectable() +export class FanavaranLocationService { + private readonly logger = new Logger(FanavaranLocationService.name); + + constructor( + private readonly fileMakerDbService: FileMakerDbService, + private readonly fileReviewerDbService: FileReviewerDbService, + ) {} + + isParsianClient(clientKey?: FanavaranClientKey): boolean { + return (clientKey ?? resolveFanavaranClientKey()) === "parsian"; + } + + extractLocationIds(locations?: LocationEntry[] | null): string[] { + if (!Array.isArray(locations) || locations.length === 0) return []; + const ids: string[] = []; + for (const entry of locations) { + const id = String(entry?.id ?? "").trim(); + if (id && !ids.includes(id)) ids.push(id); + } + return ids; + } + + primaryLocationId(locations?: LocationEntry[] | null): string | undefined { + return this.extractLocationIds(locations)[0]; + } + + /** + * Maker primary location → reviewer primary → tenant auth.location. + * Non-parsian or non-FileMaker flows always use the tenant default. + */ + async resolveBusinessLocation(input: { + clientKey: FanavaranClientKey; + isMadeByFileMaker?: boolean; + fileMakerId?: string | null; + fileReviewerId?: string | null; + }): Promise { + const defaultLocation = getFanavaranClientProfile(input.clientKey).auth + .location; + + if (input.clientKey !== "parsian" || input.isMadeByFileMaker !== true) { + return defaultLocation; + } + + const makerLocation = await this.loadPrimaryLocationId(input.fileMakerId, "maker"); + if (makerLocation) { + this.logger.debug( + `Fanavaran Location from file-maker id=${input.fileMakerId} → ${makerLocation}`, + ); + return makerLocation; + } + + const reviewerLocation = await this.loadPrimaryLocationId( + input.fileReviewerId, + "reviewer", + ); + if (reviewerLocation) { + this.logger.debug( + `Fanavaran Location from file-reviewer id=${input.fileReviewerId} → ${reviewerLocation}`, + ); + return reviewerLocation; + } + + this.logger.debug( + `Fanavaran Location fallback to tenant default → ${defaultLocation}`, + ); + return defaultLocation; + } + + /** + * When both maker and reviewer have locations, they must share at least one id. + * No-op for non-parsian deployments or when either side has no locations. + */ + async assertMakerReviewerLocationCompatible(input: { + fileMakerId?: string | null; + fileReviewerId?: string | null; + clientKey?: FanavaranClientKey; + }): Promise { + if (!this.isParsianClient(input.clientKey)) { + return; + } + + const makerIds = await this.loadLocationIds(input.fileMakerId, "maker"); + const reviewerIds = await this.loadLocationIds( + input.fileReviewerId, + "reviewer", + ); + + if (makerIds.length === 0 || reviewerIds.length === 0) { + return; + } + + const shared = makerIds.some((id) => reviewerIds.includes(id)); + if (!shared) { + throw new ForbiddenException( + "FileReviewer location must match the FileMaker location for this case.", + ); + } + } + + private async loadPrimaryLocationId( + userId: string | null | undefined, + kind: "maker" | "reviewer", + ): Promise { + const ids = await this.loadLocationIds(userId, kind); + return ids[0]; + } + + private async loadLocationIds( + userId: string | null | undefined, + kind: "maker" | "reviewer", + ): Promise { + if (!userId) return []; + try { + const doc = + kind === "maker" + ? await this.fileMakerDbService.findById(String(userId)) + : await this.fileReviewerDbService.findById(String(userId)); + return this.extractLocationIds((doc as any)?.locations); + } catch { + return []; + } + } +} diff --git a/src/fanavaran/fanavaran-lookup.module.ts b/src/fanavaran/fanavaran-lookup.module.ts index 178b33a..1003ce9 100644 --- a/src/fanavaran/fanavaran-lookup.module.ts +++ b/src/fanavaran/fanavaran-lookup.module.ts @@ -7,9 +7,11 @@ import { LookupModel, LookupSchema, } from "src/lookups/entities/schema/lookup.schema"; +import { UsersModule } from "src/users/users.module"; import { FanavaranAuditModule } from "./fanavaran-audit.module"; import { FanavaranAuthService } from "./fanavaran-auth.service"; import { FanavaranClientConfigService } from "./fanavaran-client-config.service"; +import { FanavaranLocationService } from "./fanavaran-location.service"; import { FanavaranLookupService } from "./fanavaran-lookup.service"; import { FanavaranAuthToken, @@ -36,16 +38,19 @@ import { { name: LookupModel.name, schema: LookupSchema }, ]), FanavaranAuditModule, + UsersModule, ], providers: [ FanavaranClientConfigService, FanavaranAuthService, FanavaranLookupService, + FanavaranLocationService, ], exports: [ FanavaranClientConfigService, FanavaranAuthService, FanavaranLookupService, + FanavaranLocationService, ], }) export class FanavaranLookupModule {} diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index f66f5b1..9c0690c 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -9,6 +9,7 @@ import { RequestManagementDbService } from "src/request-management/entities/db-s import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { UsersModule } from "src/users/users.module"; +import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module"; import { CronModule } from "src/utils/cron/cron.module"; import { PublicIdModule } from "src/utils/public-id/public-id.module"; import { HashModule } from "src/utils/hash/hash.module"; @@ -64,6 +65,7 @@ import { CallCenterBlameV6Controller } from "./call-center-blame-v6.controller"; PlateNormalizerModule, WorkflowStepManagementModule, PlatesModule, + FanavaranLookupModule, MulterModule.register({ dest: "./files/video", }), diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 0afce0b..8016684 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -48,6 +48,7 @@ import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status. import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; +import { FanavaranLocationService } from "src/fanavaran/fanavaran-location.service"; import { isOtpExpiryActive } from "src/helpers/user-otp-expiry"; import { parseIranLocalDateTime } from "src/helpers/iran-datetime"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; @@ -617,6 +618,7 @@ export class RequestManagementService { private readonly workflowStepDbService: WorkflowStepDbService, private readonly hashService: HashService, private readonly userAuthService: UserAuthService, + private readonly fanavaranLocationService: FanavaranLocationService, ) {} /** @@ -4707,6 +4709,12 @@ export class RequestManagementService { if (!assignedId) { // Atomically claim — ignore if another reviewer won the race (they would have // been caught by the assignedId check above on their own first call). + await this.fanavaranLocationService.assertMakerReviewerLocationCompatible({ + fileMakerId: req?.initiatedByFieldExpertId + ? String(req.initiatedByFieldExpertId) + : null, + fileReviewerId: String(expert.sub), + }); await this.blameRequestDbService.findOneAndUpdate( { _id: req._id, @@ -4718,6 +4726,14 @@ export class RequestManagementService { { $set: { assignedFileReviewerId: new Types.ObjectId(String(expert.sub)) } }, { new: false }, ); + } else { + // Already assigned to this reviewer — re-check location compatibility. + await this.fanavaranLocationService.assertMakerReviewerLocationCompatible({ + fileMakerId: req?.initiatedByFieldExpertId + ? String(req.initiatedByFieldExpertId) + : null, + fileReviewerId: String(expert.sub), + }); } return; }