forked from Yara724/api
- Add ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER (V4 split flow only) - Set claim status to WAITING_FOR_FILE_REVIEWER when FileMaker uploads the last required document (v3InPersonFlow path), replacing the old behaviour that incorrectly auto-advanced to SELECT_OUTER_PARTS - advanceV3ClaimToOuterPartsIfReady: also allow canAdvance when claimCase.status === WAITING_FOR_FILE_REVIEWER so the FileReviewer can call select-outer-parts after submitting accident fields - Add WAITING_FOR_FILE_REVIEWER to CLAIM_USER_PHASE (unified-file-status) so the file still resolves to IN_PROGRESS in the unified status report - Add WAITING_FOR_FILE_REVIEWER to CLAIM_IN_PROGRESS_STATUSES (expert-panel-status-report) for the expert report bucket - Add WAITING_FOR_FILE_REVIEWER to claimInHandling set (expert-insurer.service) so insurer stats count these correctly
10368 lines
339 KiB
TypeScript
10368 lines
339 KiB
TypeScript
import {
|
||
BadGatewayException,
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
HttpException,
|
||
HttpStatus,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
ConflictException,
|
||
InternalServerErrorException,
|
||
} from "@nestjs/common";
|
||
import { Types } from "mongoose";
|
||
import { isAxiosError } from "axios";
|
||
import { unlink } from "node:fs/promises";
|
||
import { createReadStream, existsSync } from "node:fs";
|
||
import { basename } from "node:path";
|
||
import FormData from "form-data";
|
||
import { AiService } from "src/ai/ai.service";
|
||
import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
||
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
||
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
|
||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||
import { CarDamagePartDto } from "./dto/car-part.dto";
|
||
import { ClaimPartUploadDetail } from "./dto/claim-detail";
|
||
import { CreateClaimRequestDtoRs } from "./dto/create-claim.dto";
|
||
import { ClaimRequestDtoRs } from "./dto/claim-rs-dto";
|
||
import { ImageRequiredDto } from "./dto/image-required.dto";
|
||
import { MyRequestsDto } from "./dto/my-request.dto";
|
||
import { UserObjectionDto } from "./dto/user-objection.dto";
|
||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
|
||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
|
||
import {
|
||
SelectOuterPartsV2Dto,
|
||
SelectOuterPartsV2ResponseDto,
|
||
} from "./dto/select-outer-parts-v2.dto";
|
||
import {
|
||
SelectOtherPartsV2Dto,
|
||
SelectOtherPartsV2ResponseDto,
|
||
} from "./dto/select-other-parts-v2.dto";
|
||
import { SelectOtherPartsV3Dto } from "./dto/select-other-parts-v3.dto";
|
||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||
import {
|
||
UploadRequiredDocumentV2Dto,
|
||
UploadRequiredDocumentV2ResponseDto,
|
||
} from "./dto/upload-document-v2.dto";
|
||
import {
|
||
CapturePartV2Dto,
|
||
CapturePartV2ResponseDto,
|
||
VideoCaptureV2ResponseDto,
|
||
} from "./dto/capture-part-v2.dto";
|
||
import {
|
||
GetMyClaimsV2ResponseDto,
|
||
ClaimListItemV2Dto,
|
||
} from "./dto/my-claims-v2.dto";
|
||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||
import { partyPersonMatchesUser } from "src/helpers/iran-mobile";
|
||
import {
|
||
buildBlamePartyAccessOrConditions,
|
||
collectUserIdVariants,
|
||
} from "src/helpers/party-access-queries";
|
||
import {
|
||
buildOwnerUserIdAccessFilter,
|
||
ownerUserIdMatchesActor,
|
||
resolveLinkedUserIdStrings,
|
||
} from "src/helpers/user-access-resolver";
|
||
import {
|
||
blameDamagedPartyMatchesUser,
|
||
resolveClaimOwnerFieldsFromBlame,
|
||
resolveClaimOwnerParty,
|
||
resolveDamagedPartyUserId,
|
||
} from "src/helpers/blame-damaged-party";
|
||
import { claimCaseInitiatedByFieldExpert } from "src/helpers/tenant-scope";
|
||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||
import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||
import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum";
|
||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
|
||
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
|
||
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
|
||
import { VideoCaptureDbService } from "./entites/db-service/video-capture.db.service";
|
||
import { ClaimRequiredDocumentDbService } from "./entites/db-service/claim-required-document.db.service";
|
||
import {
|
||
ClaimRequestManagementModel,
|
||
UserClaimRating,
|
||
} from "./entites/schema/claim-request-management.schema";
|
||
import { PublicIdService } from "src/utils/public-id/public-id.service";
|
||
import { ImageRequiredModel } from "./entites/schema/image-required.schema";
|
||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||
import { SandHubService } from "src/sand-hub/sand-hub.service";
|
||
import {
|
||
ExpertFileActivityType,
|
||
ExpertFileKind,
|
||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||
import { UserRatingDto } from "./dto/user-rating.dto";
|
||
import {
|
||
canFinalizeExpertResend,
|
||
documentKeyAllowedForExpertResend,
|
||
mapExpertResendCarPartsForClient,
|
||
normalizeResendDocumentKeys,
|
||
normalizeResendPartKeys,
|
||
partKeyAllowedForExpertResend,
|
||
} from "src/helpers/claim-expert-resend";
|
||
import {
|
||
claimCaseStatusAllowsOwnerFactorUploadDuringRevision,
|
||
claimCaseStatusAllowsOwnerInsurerSignStep,
|
||
classifyV2ExpertPricingParts,
|
||
getActiveV2ExpertReply,
|
||
objectionDisallowedDueToOutstandingFactorWorkflow,
|
||
} from "src/helpers/claim-v2-expert-reply-workflow";
|
||
import { buildClaimDetailsV2OwnerGuidance } from "src/helpers/claim-details-v2-owner-guidance";
|
||
import {
|
||
canonicalizeClaimCarAngleKey,
|
||
getClaimCarAngleCaptureBlob,
|
||
getDamagedPartCaptureBlob,
|
||
hasClaimCarAngleCapture,
|
||
hasDamagedPartCapture,
|
||
legacyAngleDamagedPartFieldKeysToUnset,
|
||
type ClaimCarAngleKey,
|
||
} from "src/helpers/claim-car-angle-media";
|
||
import {
|
||
ClaimVehicleTypeV2,
|
||
OUTER_PARTS_BY_CAR_TYPE,
|
||
OuterPartCatalogItem,
|
||
} from "src/static/outer-car-parts-catalog";
|
||
import {
|
||
carPartDamageLabelForFactorRecord,
|
||
catalogItemToSelectedPart,
|
||
catalogLikeKeyFromPart,
|
||
coerceDamagedPartsMediaToArray,
|
||
type DamageSelectedPartV2,
|
||
migrateExpertReplyCarPartDamageToUnified,
|
||
normalizeDamageSelectedParts,
|
||
parseCatalogPartIdInput,
|
||
partLookupKey,
|
||
resolvePartCaptureIndex,
|
||
} from "src/helpers/outer-damage-parts";
|
||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||
|
||
import {
|
||
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||
capturePhaseSequenceMessage,
|
||
getClaimCaptureProgress,
|
||
isCapturePhaseDamagedPartyDocKey,
|
||
isClaimCaptureStepComplete,
|
||
} from "src/helpers/claim-capture-phase";
|
||
import { HttpService } from "@nestjs/axios";
|
||
import { firstValueFrom } from "rxjs";
|
||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||
import {
|
||
type FanavaranClientKey,
|
||
fanavaranSubmitPath,
|
||
getFanavaranClientProfile,
|
||
resolveFanavaranClientKey,
|
||
} from "src/core/config/fanavaran-client.config";
|
||
import { FanavaranAuditService } from "src/fanavaran/fanavaran-audit.service";
|
||
import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service";
|
||
import { FANAVARAN_REMOTE_LOOKUPS } from "src/fanavaran/fanavaran-lookup.config";
|
||
import type { FanavaranAuditSession } from "src/fanavaran/fanavaran-audit.types";
|
||
import {
|
||
FanavaranAuditSource,
|
||
FanavaranAuditStatus,
|
||
FanavaranAuditStep,
|
||
} from "src/fanavaran/schema/fanavaran-audit-log.schema";
|
||
import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection";
|
||
|
||
export interface FanavaranAutoSubmitResult {
|
||
attempted: boolean;
|
||
submitted: boolean;
|
||
skipped?: boolean;
|
||
skipReason?: string;
|
||
warning?: string;
|
||
claimNo?: number;
|
||
claimId?: number;
|
||
fanavaranResponse?: unknown;
|
||
}
|
||
|
||
export interface FanavaranDamageCaseSubmitResult {
|
||
attempted: boolean;
|
||
submitted: boolean;
|
||
skipped?: boolean;
|
||
skipReason?: string;
|
||
warning?: string;
|
||
claimId?: number;
|
||
dmgCaseId?: number;
|
||
fanavaranResponse?: unknown;
|
||
}
|
||
|
||
export interface FanavaranAttachmentSubmitResult {
|
||
attempted: boolean;
|
||
submitted: boolean;
|
||
skipped?: boolean;
|
||
skipReason?: string;
|
||
warning?: string;
|
||
claimId?: number;
|
||
fileId?: number;
|
||
fileName?: string;
|
||
fanavaranResponse?: unknown;
|
||
}
|
||
|
||
export interface FanavaranExpertiseSubmitResult {
|
||
attempted: boolean;
|
||
submitted: boolean;
|
||
skipped?: boolean;
|
||
skipReason?: string;
|
||
warning?: string;
|
||
claimId?: number;
|
||
dmgCaseId?: number;
|
||
expertiseId?: number;
|
||
fanavaranResponse?: unknown;
|
||
}
|
||
|
||
interface FanavaranAttachmentCandidate {
|
||
path: string;
|
||
fileName: string;
|
||
source: string;
|
||
content: Record<string, unknown>;
|
||
alreadySubmitted: boolean;
|
||
submittedFileId?: unknown;
|
||
}
|
||
|
||
const FANAVARAN_ACCIDENT_LOCATION_ADDRESS = "استان تهران شهر تهران";
|
||
const FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID = 6;
|
||
const FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT = 1000;
|
||
|
||
const FANAVARAN_PLATE_LETTER_CODE: Record<string, number> = {
|
||
الف: 1,
|
||
ب: 2,
|
||
پ: 3,
|
||
ج: 4,
|
||
د: 5,
|
||
س: 6,
|
||
ص: 7,
|
||
ط: 8,
|
||
ع: 9,
|
||
ق: 10,
|
||
ل: 11,
|
||
م: 12,
|
||
ن: 13,
|
||
و: 14,
|
||
ه: 15,
|
||
ی: 16,
|
||
ک: 17,
|
||
ژ: 18,
|
||
ت: 19,
|
||
ث: 20,
|
||
ز: 21,
|
||
ش: 22,
|
||
ف: 23,
|
||
گ: 24,
|
||
};
|
||
|
||
@Injectable()
|
||
export class ClaimRequestManagementService {
|
||
private readonly logger = new Logger(ClaimRequestManagementService.name);
|
||
|
||
// Authentication URLs and credentials (same as lookups service)
|
||
private readonly GET_APP_TOKEN_URL =
|
||
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/GetAppToken";
|
||
private readonly LOGIN_URL =
|
||
"https://apimanager.iraneit.com/BimeApiManager/api/EITAuthentication/Login";
|
||
private readonly FANAVARAN_SUBMIT_URL =
|
||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims";
|
||
|
||
// Authentication credentials
|
||
private readonly APP_NAME = "fanhab";
|
||
private readonly SECRET = "5Fa@N#A2B";
|
||
private readonly USERNAME = "fanhabUser";
|
||
private readonly PASSWORD = "Fan#@2U$3er";
|
||
|
||
// API headers
|
||
private readonly CORP_ID = "3539";
|
||
private readonly CONTRACT_ID = "263";
|
||
private readonly LOCATION = "119";
|
||
|
||
private readonly PARSIAN_FANAVARAN_CONFIG = {
|
||
appName: "ParsianService",
|
||
secret: "P@r30@n$erv!ce",
|
||
username: "ParsianServiceUser",
|
||
password: "P@r30@n123",
|
||
corpId: "543",
|
||
contractId: "28",
|
||
location: "210050",
|
||
} as const;
|
||
|
||
/** Tejaratno hardcoded Fanavaran lookup defaults (legacy). */
|
||
private readonly TEJARATNO_FANAVARAN_DEFAULTS = {
|
||
AccidentCityId: 701,
|
||
AccidentReportTypeId: 155,
|
||
AccidentVehicleUsedId: 1,
|
||
ClaimExpertId: 1589,
|
||
CompensationReferenceId: 167,
|
||
CulpritLicenceTypeId: 2,
|
||
CulpritTypeId: 337,
|
||
} as const;
|
||
|
||
/** Parsian-validated Fanavaran lookup defaults (verified via direct submit). */
|
||
private readonly PARSIAN_FANAVARAN_DEFAULTS = {
|
||
AccidentCityId: 701,
|
||
AccidentReportTypeId: 155,
|
||
AccidentVehicleUsedId: 1,
|
||
ClaimExpertId: 154,
|
||
CompensationReferenceId: 167,
|
||
CulpritLicenceTypeId: 2,
|
||
CulpritTypeId: 337,
|
||
} as const;
|
||
|
||
private readonly MAP_IR_API_KEY =
|
||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg";
|
||
|
||
constructor(
|
||
private readonly blameDbService: RequestManagementDbService,
|
||
private readonly claimDbService: ClaimRequestManagementDbService,
|
||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||
private readonly blameRequestDbService: BlameRequestDbService,
|
||
private readonly carGreenCardDbService: CarGreenCardDbService,
|
||
private readonly damageImageDbService: DamageImageDbService,
|
||
private readonly userDbService: UserDbService,
|
||
private readonly videoCaptureDbService: VideoCaptureDbService,
|
||
private readonly claimSignDbService: ClaimSignDbService,
|
||
private readonly aiService: AiService,
|
||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||
private readonly damageExpertDbService: DamageExpertDbService,
|
||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||
private readonly branchDbService: BranchDbService,
|
||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||
private readonly publicIdService: PublicIdService,
|
||
private readonly sandHubService: SandHubService,
|
||
private readonly httpService: HttpService,
|
||
private readonly fanavaranAuditService: FanavaranAuditService,
|
||
private readonly fanavaranLookupService: FanavaranLookupService,
|
||
) {}
|
||
|
||
private requiredDocumentKeysV2(isCarBody: boolean): string[] {
|
||
const damagedPartyKeys = [
|
||
"damaged_driving_license_front",
|
||
"damaged_driving_license_back",
|
||
"damaged_chassis_number",
|
||
"damaged_engine_photo",
|
||
"damaged_car_card_front",
|
||
"damaged_car_card_back",
|
||
"damaged_metal_plate",
|
||
];
|
||
if (isCarBody) return damagedPartyKeys;
|
||
return [
|
||
...damagedPartyKeys,
|
||
"guilty_driving_license_front",
|
||
"guilty_driving_license_back",
|
||
"guilty_car_card_front",
|
||
"guilty_car_card_back",
|
||
"guilty_metal_plate",
|
||
];
|
||
}
|
||
|
||
private isRequiredDocumentUploadedOnClaim(
|
||
claimCase: any,
|
||
key: string,
|
||
): boolean {
|
||
const doc =
|
||
(claimCase.requiredDocuments as any)?.get?.(key) ??
|
||
(claimCase.requiredDocuments as any)?.[key];
|
||
return !!doc?.uploaded;
|
||
}
|
||
|
||
private allV2OwnerDocumentsComplete(
|
||
claimCase: any,
|
||
isCarBody: boolean,
|
||
assumeUploadedKey?: string,
|
||
): boolean {
|
||
for (const k of this.requiredDocumentKeysV2(isCarBody)) {
|
||
const ok =
|
||
k === assumeUploadedKey
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||
if (!ok) return false;
|
||
}
|
||
if (isCarBody) {
|
||
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
|
||
const okGreen =
|
||
assumeUploadedKey === g
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, g);
|
||
if (!okGreen) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** V3 initial document step — excludes capture-phase vehicle evidence. */
|
||
private v3PreCaptureDocumentKeys(isCarBody: boolean): string[] {
|
||
return this.requiredDocumentKeysV2(isCarBody).filter(
|
||
(k) => !isCapturePhaseDamagedPartyDocKey(k),
|
||
);
|
||
}
|
||
|
||
private allV3PreCaptureDocumentsComplete(
|
||
claimCase: any,
|
||
isCarBody: boolean,
|
||
assumeUploadedKey?: string,
|
||
): boolean {
|
||
for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) {
|
||
const ok =
|
||
k === assumeUploadedKey
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||
if (!ok) return false;
|
||
}
|
||
if (isCarBody) {
|
||
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
|
||
const ok =
|
||
assumeUploadedKey === g
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, g);
|
||
if (!ok) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private countRemainingV3PreCaptureDocuments(
|
||
claimCase: any,
|
||
isCarBody: boolean,
|
||
assumeUploadedKey?: string,
|
||
): number {
|
||
let n = 0;
|
||
for (const k of this.v3PreCaptureDocumentKeys(isCarBody)) {
|
||
const ok =
|
||
k === assumeUploadedKey
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||
if (!ok) n++;
|
||
}
|
||
if (isCarBody) {
|
||
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
|
||
const ok =
|
||
assumeUploadedKey === g
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, g);
|
||
if (!ok) n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
private countRemainingV2OwnerDocuments(
|
||
claimCase: any,
|
||
isCarBody: boolean,
|
||
assumeUploadedKey?: string,
|
||
): number {
|
||
let n = 0;
|
||
for (const k of this.requiredDocumentKeysV2(isCarBody)) {
|
||
const ok =
|
||
k === assumeUploadedKey
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||
if (!ok) n++;
|
||
}
|
||
if (isCarBody) {
|
||
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
|
||
const okGreen =
|
||
assumeUploadedKey === g
|
||
? true
|
||
: this.isRequiredDocumentUploadedOnClaim(claimCase, g);
|
||
if (!okGreen) n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
private parsePlateFromCompactString(plateId: string | undefined): {
|
||
leftDigits: number;
|
||
centerAlphabet: string;
|
||
centerDigits: number;
|
||
ir: number;
|
||
} | null {
|
||
if (!plateId) return null;
|
||
const parts = String(plateId).split("-");
|
||
if (parts.length !== 4) return null;
|
||
const [irRaw, leftRaw, alphaRaw, centerRaw] = parts;
|
||
const ir = Number(irRaw);
|
||
const leftDigits = Number(leftRaw);
|
||
const centerDigits = Number(centerRaw);
|
||
const centerAlphabet = String(alphaRaw || "").trim();
|
||
if (
|
||
!Number.isFinite(ir) ||
|
||
!Number.isFinite(leftDigits) ||
|
||
!Number.isFinite(centerDigits) ||
|
||
!centerAlphabet
|
||
) {
|
||
return null;
|
||
}
|
||
return { leftDigits, centerAlphabet, centerDigits, ir };
|
||
}
|
||
|
||
private resolveOwnershipPlateForClaim(
|
||
claimCase: any,
|
||
blameRequest?: any,
|
||
): {
|
||
leftDigits: number;
|
||
centerAlphabet: string;
|
||
centerDigits: number;
|
||
ir: number;
|
||
} | null {
|
||
const p = claimCase?.vehicle?.plate;
|
||
if (
|
||
p &&
|
||
Number.isFinite(Number(p.leftDigits)) &&
|
||
Number.isFinite(Number(p.centerDigits)) &&
|
||
Number.isFinite(Number(p.ir)) &&
|
||
String(p.centerAlphabet || "").trim()
|
||
) {
|
||
return {
|
||
leftDigits: Number(p.leftDigits),
|
||
centerAlphabet: String(p.centerAlphabet).trim(),
|
||
centerDigits: Number(p.centerDigits),
|
||
ir: Number(p.ir),
|
||
};
|
||
}
|
||
|
||
if (!blameRequest || !Array.isArray(blameRequest.parties)) return null;
|
||
const ownerUserId = claimCase?.owner?.userId?.toString?.();
|
||
const matchedParty =
|
||
blameRequest.parties.find(
|
||
(party: any) =>
|
||
String(party?.person?.userId || "") === String(ownerUserId || ""),
|
||
) ||
|
||
blameRequest.parties.find(
|
||
(party: any) => party?.role === PartyRole.FIRST,
|
||
);
|
||
const compactPlateId = matchedParty?.vehicle?.plateId;
|
||
return this.parsePlateFromCompactString(compactPlateId);
|
||
}
|
||
|
||
private delay(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/**
|
||
* Outer-parts catalog returned to clients (user app + expert panel).
|
||
*
|
||
* The shape intentionally mirrors how items are persisted under
|
||
* `damage.selectedParts` so the front-end can match catalog rows to the
|
||
* stored selection by `id` / `catalogKey` without renaming fields:
|
||
*
|
||
* { id, name, side, label_fa, catalogKey, carType }
|
||
*
|
||
* - `name` is side-agnostic (`backWheel`, not `left_backWheel`).
|
||
* - `label_fa` is disambiguated with the side in parentheses when multiple
|
||
* catalog rows share the same Farsi title (e.g. `چرخ عقب (چپ)`).
|
||
* - `catalogKey` keeps the original full key (`left_backWheel`) for clients
|
||
* that already address parts by it.
|
||
*/
|
||
getOuterPartsCatalogV2(
|
||
carType?: ClaimVehicleTypeV2,
|
||
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] {
|
||
const buildForType = (
|
||
type: ClaimVehicleTypeV2,
|
||
items: OuterPartCatalogItem[],
|
||
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] =>
|
||
items.map((p) => ({
|
||
...catalogItemToSelectedPart(p, items),
|
||
carType: type,
|
||
}));
|
||
|
||
if (carType) {
|
||
const items = OUTER_PARTS_BY_CAR_TYPE[carType] || [];
|
||
return buildForType(carType, items);
|
||
}
|
||
|
||
const out: (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] = [];
|
||
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
|
||
out.push(...buildForType(type as ClaimVehicleTypeV2, items));
|
||
}
|
||
return out.sort((a, b) => {
|
||
if (a.carType === b.carType) return (a.id ?? 0) - (b.id ?? 0);
|
||
return String(a.carType).localeCompare(String(b.carType));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Internal raw catalog (kept in the original `OuterPartCatalogItem` shape so
|
||
* existing internal code that indexes by `key`/`titleFa` keeps working).
|
||
* Public API consumers should use `getOuterPartsCatalogV2` instead.
|
||
*/
|
||
private getOuterPartsRawCatalog(
|
||
carType?: ClaimVehicleTypeV2,
|
||
): OuterPartCatalogItem[] {
|
||
if (carType) {
|
||
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
|
||
...p,
|
||
carType,
|
||
}));
|
||
}
|
||
const out: OuterPartCatalogItem[] = [];
|
||
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
|
||
const t = type as ClaimVehicleTypeV2;
|
||
for (const p of items) {
|
||
out.push({ ...p, carType: t });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private userDamageDetail(blRequest: RequestManagementModel) {
|
||
const { firstPartyDetails: first, secondPartyDetails: second } = blRequest;
|
||
switch (blRequest.expertSubmitReply.guiltyUserId) {
|
||
case first.firstPartyId:
|
||
return { isGuilty: first, isNotGuilty: second };
|
||
case second.secondPartyId:
|
||
return { isGuilty: second, isNotGuilty: first };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper method to get the effective user ID for claim operations.
|
||
* For experts accessing IN_PERSON claim files, returns the claim file's userId.
|
||
* For regular users, returns their own ID.
|
||
*/
|
||
private async getEffectiveUserId(
|
||
claimRequestId: string,
|
||
actor: any,
|
||
): Promise<string> {
|
||
// If actor is a user, return their ID
|
||
if (actor.role === RoleEnum.USER) {
|
||
return actor.sub;
|
||
}
|
||
|
||
// If actor is an expert, verify they're accessing an IN_PERSON claim file
|
||
// and return the claim file's userId
|
||
const expertRoles = [
|
||
RoleEnum.EXPERT,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
RoleEnum.FIELD_EXPERT,
|
||
];
|
||
if (expertRoles.includes(actor.role)) {
|
||
const claim = await this.claimDbService.findOne(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
const blameFile = claim.blameFile;
|
||
if (
|
||
blameFile?.expertInitiated &&
|
||
blameFile?.creationMethod === "IN_PERSON" &&
|
||
String(blameFile.initiatedBy) === actor.sub
|
||
) {
|
||
// Expert is accessing IN_PERSON file they initiated - use claim's userId
|
||
return String(claim.userId);
|
||
}
|
||
|
||
throw new ForbiddenException(
|
||
"Experts can only access IN_PERSON claim files they initiated",
|
||
);
|
||
}
|
||
|
||
throw new ForbiddenException("Invalid role");
|
||
}
|
||
|
||
async createClaimRequest(
|
||
requestId: string,
|
||
CurrentUserId?: string,
|
||
actor?: any,
|
||
) {
|
||
try {
|
||
const blameRequest = await this.blameDbService.findOne(requestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Source blame request not found.");
|
||
}
|
||
|
||
// Ensure the blame request has a shared human-friendly id.
|
||
// For legacy records, generate it lazily exactly once.
|
||
let publicId = (blameRequest as any).publicId as string | undefined;
|
||
if (!publicId) {
|
||
publicId = await this.publicIdService.generateRequestPublicId();
|
||
await this.blameDbService.findAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{ $set: { publicId } },
|
||
);
|
||
}
|
||
|
||
const existingClaimCount = await this.claimDbService.countByFilter({
|
||
"blameFile._id": new Types.ObjectId(requestId),
|
||
blameRequestId: new Types.ObjectId(requestId),
|
||
publicId,
|
||
});
|
||
if (existingClaimCount > 0) {
|
||
throw new ForbiddenException(
|
||
"A claim request for this blame file already exists.",
|
||
);
|
||
}
|
||
|
||
const isStatusValid =
|
||
blameRequest.blameStatus === ReqBlameStatus.CloseRequest;
|
||
|
||
if (!isStatusValid) {
|
||
throw new HttpException(
|
||
`Blame request must be 'CloseRequest', but is currently '${blameRequest.blameStatus}'.`,
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
if (!blameRequest.expertSubmitReply) {
|
||
throw new HttpException(
|
||
"Cannot create a claim file until the expert has submitted a reply.",
|
||
HttpStatus.GONE,
|
||
);
|
||
}
|
||
|
||
// Determine the damaged user (non-guilty party)
|
||
const guiltyUserId = String(blameRequest.expertSubmitReply.guiltyUserId);
|
||
let damagedUserId: string;
|
||
|
||
// For IN_PERSON expert-initiated files, expert creates the claim
|
||
// For LINK method or regular files, user creates their own claim
|
||
const expertRoles = [
|
||
RoleEnum.EXPERT,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
RoleEnum.FIELD_EXPERT,
|
||
];
|
||
if (
|
||
blameRequest.expertInitiated &&
|
||
blameRequest.creationMethod === "IN_PERSON" &&
|
||
actor &&
|
||
expertRoles.includes(actor.role)
|
||
) {
|
||
// Expert is creating claim for IN_PERSON file
|
||
// Verify expert is the initiating expert
|
||
if (String(blameRequest.initiatedBy) !== actor.sub) {
|
||
throw new ForbiddenException(
|
||
"Only the initiating expert can create claim files for IN_PERSON files.",
|
||
);
|
||
}
|
||
|
||
// Determine damaged user (the one who is NOT guilty)
|
||
if (
|
||
String(blameRequest.firstPartyDetails.firstPartyId) === guiltyUserId
|
||
) {
|
||
damagedUserId = String(blameRequest.secondPartyDetails.secondPartyId);
|
||
} else {
|
||
damagedUserId = String(blameRequest.firstPartyDetails.firstPartyId);
|
||
}
|
||
} else {
|
||
// Regular user flow
|
||
if (!CurrentUserId) {
|
||
throw new BadRequestException("User ID is required.");
|
||
}
|
||
if (guiltyUserId === CurrentUserId) {
|
||
throw new HttpException(
|
||
"You cannot submit a claim file as you are the guilty party.",
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
}
|
||
damagedUserId = CurrentUserId;
|
||
}
|
||
|
||
const userDetail = await this.userDbService.findOne({
|
||
_id: new Types.ObjectId(damagedUserId),
|
||
});
|
||
if (!userDetail) {
|
||
throw new NotFoundException("User details could not be found.");
|
||
}
|
||
|
||
const carDetail =
|
||
String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId
|
||
? blameRequest.firstPartyDetails.firstPartyCarDetail
|
||
: blameRequest.secondPartyDetails.secondPartyCarDetail;
|
||
|
||
const carPlate =
|
||
String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId
|
||
? blameRequest.firstPartyDetails.firstPartyPlate
|
||
: blameRequest.secondPartyDetails.secondPartyPlate;
|
||
|
||
const newClaimPayload = {
|
||
blameFile: blameRequest,
|
||
userId: new Types.ObjectId(damagedUserId),
|
||
claimStatus: ReqClaimStatus.WaitingForUserCompleted,
|
||
steps: [ClaimStepsEnum.CreateClaimFile],
|
||
currentStep: ClaimStepsEnum.CreateClaimFile,
|
||
nextStep: ClaimStepsEnum.SelectDamagePart,
|
||
fullName: userDetail.fullName,
|
||
carDetail,
|
||
carPlate,
|
||
userClientKey: new Types.ObjectId(userDetail.clientKey),
|
||
createdAt: new Date(),
|
||
};
|
||
|
||
const response = await this.claimDbService.create(newClaimPayload as any);
|
||
|
||
return new CreateClaimRequestDtoRs(
|
||
response["_id"],
|
||
"Claim file created successfully.",
|
||
);
|
||
} catch (er) {
|
||
this.logger.error(er);
|
||
|
||
if (er instanceof HttpException) {
|
||
throw er;
|
||
}
|
||
|
||
if (er.code === 11000) {
|
||
throw new HttpException(
|
||
"Duplicate claim file reference.",
|
||
HttpStatus.CONFLICT,
|
||
);
|
||
}
|
||
|
||
throw new HttpException(
|
||
"An internal server error occurred.",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
private findTrueItems(userPartSelect: CarDamagePartDto) {
|
||
const trueItems = [];
|
||
for (const side in userPartSelect) {
|
||
for (const parts in userPartSelect[side]) {
|
||
if (userPartSelect[side][parts] === true) {
|
||
trueItems.push({ side, part: parts });
|
||
}
|
||
}
|
||
}
|
||
return trueItems;
|
||
}
|
||
|
||
/** One section of CarDamagePartDto: either a single object or an array of objects (legacy). */
|
||
private carPartDamageSectionToRows(
|
||
section: unknown,
|
||
): Record<string, unknown>[] {
|
||
if (section == null) return [];
|
||
if (Array.isArray(section)) {
|
||
return section.filter(
|
||
(row): row is Record<string, unknown> =>
|
||
!!row && typeof row === "object",
|
||
);
|
||
}
|
||
if (typeof section === "object") {
|
||
return [section as Record<string, unknown>];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
private carPartDamageSectionHasTrue(section: unknown): boolean {
|
||
for (const row of this.carPartDamageSectionToRows(section)) {
|
||
for (const val of Object.values(row)) {
|
||
if (val === true) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* At most two of left, right, front, back may contain any damaged part (true).
|
||
* `top` and claim `otherParts` are not subject to this rule.
|
||
*/
|
||
private assertCarPartDamageAtMostTwoOfFourSides(dto: CarDamagePartDto): void {
|
||
const d = dto as unknown as Record<string, unknown>;
|
||
const quadrants = [
|
||
{ key: "left", section: d.left },
|
||
{ key: "right", section: d.right },
|
||
{ key: "front", section: d.front },
|
||
{ key: "back", section: d.back },
|
||
];
|
||
const used = quadrants.filter((q) =>
|
||
this.carPartDamageSectionHasTrue(q.section),
|
||
);
|
||
if (used.length > 2) {
|
||
throw new BadRequestException(
|
||
`At most two of the four sides (left, right, front, back) may include damaged parts. ` +
|
||
`Sides with selections: ${used.map((u) => u.key).join(", ")}.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Maps legacy expert CarDamagePartDto (nested booleans) to v2 outer part slugs (ClaimCase.damage.selectedParts).
|
||
*/
|
||
private carDamagePartDtoToOuterPartSlugs(dto: CarDamagePartDto): string[] {
|
||
const out = new Set<string>();
|
||
const add = (slug: string | undefined) => {
|
||
if (slug) out.add(slug);
|
||
};
|
||
const walkMainOnSide = (
|
||
side: "left" | "right",
|
||
items: unknown,
|
||
map: Record<string, string>,
|
||
) => {
|
||
for (const row of this.carPartDamageSectionToRows(items)) {
|
||
for (const [key, val] of Object.entries(row)) {
|
||
if (val !== true) continue;
|
||
const slug = map[`${side}_${key}`] ?? map[key];
|
||
add(slug);
|
||
}
|
||
}
|
||
};
|
||
// MainParts on left / right (side-specific door / fender)
|
||
walkMainOnSide("left", (dto as any).left, {
|
||
left_frontDoor: "front_left_door",
|
||
left_backDoor: "rear_left_door",
|
||
left_frontFender: "front_left_fender",
|
||
left_backFender: "rear_left_fender",
|
||
frontDoor: "front_left_door",
|
||
backDoor: "rear_left_door",
|
||
frontFender: "front_left_fender",
|
||
backFender: "rear_left_fender",
|
||
});
|
||
walkMainOnSide("right", (dto as any).right, {
|
||
right_frontDoor: "front_right_door",
|
||
right_backDoor: "rear_right_door",
|
||
right_frontFender: "front_right_fender",
|
||
right_backFender: "rear_right_fender",
|
||
frontDoor: "front_right_door",
|
||
backDoor: "rear_right_door",
|
||
frontFender: "front_right_fender",
|
||
backFender: "rear_right_fender",
|
||
});
|
||
const walkSimple = (items: unknown, keyToSlug: Record<string, string>) => {
|
||
for (const row of this.carPartDamageSectionToRows(items)) {
|
||
for (const [key, val] of Object.entries(row)) {
|
||
if (val === true) add(keyToSlug[key]);
|
||
}
|
||
}
|
||
};
|
||
walkSimple((dto as any).front, {
|
||
frontBumper: "front_bumper",
|
||
carHood: "hood",
|
||
});
|
||
walkSimple((dto as any).back, {
|
||
backBumper: "rear_bumper",
|
||
carTrunk: "trunk",
|
||
});
|
||
walkSimple((dto as any).top, {
|
||
roof: "roof",
|
||
});
|
||
return Array.from(out);
|
||
}
|
||
|
||
/** Normalize expert DTO otherParts (strings or legacy { label: true }[]) to string[]. */
|
||
private normalizeExpertOtherPartsInput(raw: any[] | undefined): string[] {
|
||
if (!raw || !Array.isArray(raw)) return [];
|
||
const out: string[] = [];
|
||
for (const item of raw) {
|
||
if (typeof item === "string" && item.trim()) {
|
||
out.push(item.trim());
|
||
continue;
|
||
}
|
||
if (item && typeof item === "object") {
|
||
for (const [k, v] of Object.entries(item)) {
|
||
if (v === true && k) out.push(k);
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private async assertFieldExpertInPersonBlameForClaimCase(
|
||
claimCase: any,
|
||
expert: any,
|
||
): Promise<void> {
|
||
const blameRequestId = claimCase?.blameRequestId?.toString?.();
|
||
if (!blameRequestId) {
|
||
throw new BadRequestException("Claim case has no linked blame request.");
|
||
}
|
||
const blameRequest =
|
||
await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found for this claim.");
|
||
}
|
||
if (
|
||
!blameRequest.expertInitiated ||
|
||
blameRequest.creationMethod !== CreationMethod.IN_PERSON
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON claim files.",
|
||
);
|
||
}
|
||
if (!blameRequest.initiatedByFieldExpertId) {
|
||
throw new BadRequestException(
|
||
"Blame request is missing initiating field expert.",
|
||
);
|
||
}
|
||
if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) {
|
||
throw new ForbiddenException(
|
||
"Only the initiating field expert can complete claim data for this file.",
|
||
);
|
||
}
|
||
}
|
||
|
||
async setCarPartDamageAndImage(cl, trueItems) {
|
||
cl.carPartDamage = trueItems;
|
||
const jsonTrueItems = JSON.parse(JSON.stringify(trueItems));
|
||
cl.imageRequired = new ImageRequiredModel(jsonTrueItems);
|
||
cl.save();
|
||
return cl.imageRequired;
|
||
}
|
||
|
||
async selectCarPartDamage(
|
||
requestId: string,
|
||
userPartSelect: CarDamagePartDto,
|
||
actor: any,
|
||
): Promise<any> {
|
||
try {
|
||
const cl = await this.claimDbService.findOne(requestId);
|
||
|
||
if (!cl) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
if (String(cl.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException(
|
||
"You do not have access to this claim file",
|
||
);
|
||
}
|
||
|
||
if (cl.carPartDamage) {
|
||
throw new HttpException(
|
||
"car damage part has exist",
|
||
HttpStatus.CONFLICT,
|
||
);
|
||
}
|
||
|
||
const trueItems = this.findTrueItems(userPartSelect);
|
||
const damageParts = this.setCarPartDamageAndImage(cl, trueItems);
|
||
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
currentStep: ClaimStepsEnum.SelectDamagePart,
|
||
nextStep: ClaimStepsEnum.SelectOtherParts,
|
||
$push: {
|
||
steps: ClaimStepsEnum.SelectDamagePart,
|
||
},
|
||
});
|
||
|
||
return damageParts;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Field expert completes claim-needed data for expert-initiated IN_PERSON files.
|
||
* Only the initiating field expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts.
|
||
*
|
||
* V2: Resolves `ClaimCase` in `claimCases` collection (workflow). Legacy v1 claim documents are still supported.
|
||
*/
|
||
async expertCompleteClaimData(
|
||
claimRequestId: string,
|
||
expert: any,
|
||
dto: {
|
||
carPartDamage?: CarDamagePartDto;
|
||
sheba?: string;
|
||
nationalCodeOfInsurer?: string;
|
||
otherParts?: any[];
|
||
},
|
||
): Promise<{ success: boolean; claimRequestId: string }> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (claimCase) {
|
||
return this.expertCompleteClaimDataForClaimCaseV2(
|
||
claimRequestId,
|
||
expert,
|
||
dto,
|
||
claimCase,
|
||
);
|
||
}
|
||
|
||
const claim = await this.claimDbService.findOne(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
const blameFile = claim.blameFile as any;
|
||
if (
|
||
!blameFile?.expertInitiated ||
|
||
blameFile?.creationMethod !== "IN_PERSON"
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON claim files.",
|
||
);
|
||
}
|
||
if (String(blameFile.initiatedBy) !== expert.sub) {
|
||
throw new ForbiddenException(
|
||
"Only the initiating field expert can complete claim data for this file.",
|
||
);
|
||
}
|
||
|
||
if (dto.carPartDamage) {
|
||
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
||
throw new BadRequestException("Car part damage is already set.");
|
||
}
|
||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
||
const trueItems = this.findTrueItems(dto.carPartDamage);
|
||
const claimDoc = (await this.claimDbService.findOne(
|
||
claimRequestId,
|
||
)) as any;
|
||
if (claimDoc) {
|
||
await this.setCarPartDamageAndImage(claimDoc, trueItems);
|
||
}
|
||
await this.claimDbService.findAndUpdate(claimRequestId, {
|
||
currentStep: ClaimStepsEnum.SelectDamagePart,
|
||
nextStep: ClaimStepsEnum.SelectOtherParts,
|
||
$push: { steps: ClaimStepsEnum.SelectDamagePart },
|
||
});
|
||
}
|
||
|
||
const updates: Record<string, any> = {};
|
||
if (dto.sheba != null) updates.sheba = dto.sheba;
|
||
if (dto.nationalCodeOfInsurer != null)
|
||
updates.nationalCodeOfInsurer = dto.nationalCodeOfInsurer;
|
||
if (dto.otherParts != null) updates.otherParts = dto.otherParts;
|
||
if (Object.keys(updates).length > 0) {
|
||
await this.claimDbService.findAndUpdate(claimRequestId, {
|
||
$set: updates,
|
||
});
|
||
}
|
||
|
||
return { success: true, claimRequestId };
|
||
}
|
||
|
||
/**
|
||
* V2 ClaimCase: outer parts + optional bank/other parts (expert-initiated IN_PERSON only).
|
||
*/
|
||
private async expertCompleteClaimDataForClaimCaseV2(
|
||
claimRequestId: string,
|
||
expert: any,
|
||
dto: {
|
||
carPartDamage?: CarDamagePartDto;
|
||
sheba?: string;
|
||
nationalCodeOfInsurer?: string;
|
||
otherParts?: any[];
|
||
},
|
||
claimCase: any,
|
||
): Promise<{ success: boolean; claimRequestId: string }> {
|
||
await this.assertFieldExpertInPersonBlameForClaimCase(claimCase, expert);
|
||
|
||
let working = claimCase;
|
||
|
||
if (dto.carPartDamage) {
|
||
if (
|
||
working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS
|
||
) {
|
||
throw new BadRequestException(
|
||
`Cannot set outer parts: workflow step must be ${ClaimWorkflowStep.SELECT_OUTER_PARTS}. Current: ${working.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
if (
|
||
working.damage?.selectedParts &&
|
||
working.damage.selectedParts.length > 0
|
||
) {
|
||
throw new BadRequestException("Car part damage is already set.");
|
||
}
|
||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
||
const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
|
||
dto.carPartDamage,
|
||
);
|
||
if (!selectedSlugStrings.length) {
|
||
throw new BadRequestException(
|
||
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
|
||
);
|
||
}
|
||
const selectedPartObjs = normalizeDamageSelectedParts(
|
||
selectedSlugStrings,
|
||
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||
undefined,
|
||
);
|
||
const damagedPartsStubs = selectedPartObjs.map((p) => ({
|
||
id: p.id ?? undefined,
|
||
name: p.name,
|
||
side: p.side,
|
||
label_fa: p.label_fa,
|
||
...(p.catalogKey ? { catalogKey: p.catalogKey } : {}),
|
||
}));
|
||
|
||
const updated = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
{
|
||
"damage.selectedParts": selectedPartObjs,
|
||
"media.damagedParts": damagedPartsStubs,
|
||
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
history: {
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName:
|
||
`${expert.firstName || ""} ${expert.lastName || ""}`.trim() ||
|
||
"field_expert",
|
||
actorType: "field_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
selectedParts: selectedPartObjs,
|
||
description: "Field expert submitted outer damage parts",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
);
|
||
if (!updated) {
|
||
throw new InternalServerErrorException("Failed to update claim case");
|
||
}
|
||
working = updated;
|
||
}
|
||
|
||
const hasBankOrOther =
|
||
dto.sheba != null ||
|
||
dto.nationalCodeOfInsurer != null ||
|
||
dto.otherParts != null;
|
||
if (hasBankOrOther) {
|
||
const fresh = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!fresh) throw new NotFoundException("Claim case not found");
|
||
if (
|
||
fresh.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS
|
||
) {
|
||
throw new BadRequestException(
|
||
`Bank/other parts can only be set at ${ClaimWorkflowStep.SELECT_OTHER_PARTS}. Current: ${fresh.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
|
||
const mergedSheba = dto.sheba != null ? dto.sheba : fresh.money?.sheba;
|
||
const mergedNational =
|
||
dto.nationalCodeOfInsurer != null
|
||
? dto.nationalCodeOfInsurer
|
||
: fresh.money?.nationalCodeOfInsurer;
|
||
|
||
if (
|
||
fresh.money?.sheba &&
|
||
dto.sheba != null &&
|
||
dto.sheba !== fresh.money.sheba
|
||
) {
|
||
throw new ConflictException("Sheba is already set for this claim.");
|
||
}
|
||
if (
|
||
fresh.money?.nationalCodeOfInsurer &&
|
||
dto.nationalCodeOfInsurer != null &&
|
||
dto.nationalCodeOfInsurer !== fresh.money.nationalCodeOfInsurer
|
||
) {
|
||
throw new ConflictException(
|
||
"National code of insurer is already set for this claim.",
|
||
);
|
||
}
|
||
|
||
const otherPartsArr = this.normalizeExpertOtherPartsInput(dto.otherParts);
|
||
const setPatch: Record<string, unknown> = {};
|
||
if (dto.sheba != null) setPatch["money.sheba"] = dto.sheba;
|
||
if (dto.nationalCodeOfInsurer != null) {
|
||
setPatch["money.nationalCodeOfInsurer"] = dto.nationalCodeOfInsurer;
|
||
}
|
||
if (dto.otherParts != null) {
|
||
setPatch["damage.otherParts"] =
|
||
otherPartsArr.length > 0 ? otherPartsArr : [];
|
||
}
|
||
|
||
const bankComplete =
|
||
mergedSheba &&
|
||
mergedNational &&
|
||
/^[0-9]{24}$/.test(String(mergedSheba).replace(/\s/g, "")) &&
|
||
/^[0-9]{10}$/.test(String(mergedNational).replace(/\s/g, ""));
|
||
|
||
if (bankComplete) {
|
||
const shebaNorm = String(mergedSheba).replace(/\s/g, "");
|
||
const nationalNorm = String(mergedNational).replace(/\s/g, "");
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
"money.sheba": shebaNorm,
|
||
"money.nationalCodeOfInsurer": nationalNorm,
|
||
...(dto.otherParts != null
|
||
? {
|
||
"damage.otherParts":
|
||
otherPartsArr.length > 0 ? otherPartsArr : [],
|
||
}
|
||
: {}),
|
||
status: ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
history: {
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName:
|
||
`${expert.firstName || ""} ${expert.lastName || ""}`.trim() ||
|
||
"field_expert",
|
||
actorType: "field_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
description:
|
||
"Field expert submitted bank info and optional other parts",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
} else {
|
||
if (Object.keys(setPatch).length === 0) {
|
||
throw new BadRequestException(
|
||
"Provide sheba (24 digits) and nationalCodeOfInsurer (10 digits) together to finish this step, or supply partial fields incrementally.",
|
||
);
|
||
}
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: setPatch,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { success: true, claimRequestId };
|
||
}
|
||
|
||
async selectCarOtherPartDamage(
|
||
requestId: string,
|
||
body: any, // Use your DTO for better type safety
|
||
file: Express.Multer.File,
|
||
actor: any,
|
||
) {
|
||
try {
|
||
// 1. Fetch the claim request and perform initial validation
|
||
const claimRequest = await this.claimDbService.findOne(requestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
if (String(claimRequest.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException(
|
||
"You do not have access to this claim file",
|
||
);
|
||
}
|
||
|
||
if (
|
||
String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) ===
|
||
effectiveUserId
|
||
) {
|
||
throw new ForbiddenException(
|
||
"User access denied. You are the guilty party in the blame file.",
|
||
);
|
||
}
|
||
|
||
if (!claimRequest.carPlate) {
|
||
throw new BadRequestException(
|
||
"Cannot validate ownership: Plate information is missing from the claim file.",
|
||
);
|
||
}
|
||
|
||
// await this.sandHubService.getCarOwnershipInfo(
|
||
// claimRequest.carPlate,
|
||
// body.nationalCodeOfInsurer,
|
||
// );
|
||
|
||
// await this.sandHubService.getShebaValidation(
|
||
// body.nationalCodeOfInsurer,
|
||
// body.sheba,
|
||
// );
|
||
|
||
const greenCard = await this.carGreenCardDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: requestId,
|
||
});
|
||
if (!greenCard) {
|
||
throw new BadGatewayException(
|
||
"Failed to save car green card document.",
|
||
);
|
||
}
|
||
|
||
const updatePayload = {
|
||
$set: {
|
||
otherParts: JSON.parse(body.otherParts as any),
|
||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||
sheba: body.sheba,
|
||
carGreenCard: {
|
||
path: buildFileLink(greenCard.path),
|
||
fileName: greenCard.fileName,
|
||
claimId: requestId,
|
||
},
|
||
currentStep: ClaimStepsEnum.SelectOtherParts,
|
||
nextStep: ClaimStepsEnum.ImageRequired,
|
||
claimStatus: ReqClaimStatus.WaitingForUserCompleted,
|
||
},
|
||
$push: {
|
||
steps: ClaimStepsEnum.SelectOtherParts,
|
||
},
|
||
};
|
||
|
||
this.logger.log(
|
||
`[CLAIM] Moving to ImageRequired step for claim ${requestId}. Status set to: ${ReqClaimStatus.WaitingForUserCompleted}`,
|
||
);
|
||
|
||
const updatedClaim = await this.claimDbService.findByIdAndUpdate(
|
||
requestId,
|
||
updatePayload,
|
||
);
|
||
|
||
// 6. Return the successful response
|
||
return new ClaimPartUploadDetail([updatedClaim]);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in selectCarOtherPartDamage: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
// Re-throw the specific error from the validation steps or a generic one.
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get the list of required document types based on claim type and whether there's a second party car
|
||
* For CAR_BODY type with accident with another object (not a car), only require 7 damaged party documents
|
||
* Otherwise, require all 12 documents (7 damaged + 5 guilty party)
|
||
*/
|
||
private getRequiredDocumentTypes(
|
||
claimRequest: any,
|
||
): ClaimRequiredDocumentType[] {
|
||
const allTypes = Object.values(ClaimRequiredDocumentType);
|
||
|
||
// Check if it's CAR_BODY type
|
||
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
|
||
|
||
if (isCarBody) {
|
||
// Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false
|
||
const firstPartyCarBody =
|
||
claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm;
|
||
const isAccidentWithOtherObject =
|
||
(firstPartyCarBody && firstPartyCarBody.object === true) ||
|
||
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
|
||
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail;
|
||
|
||
if (isAccidentWithOtherObject) {
|
||
this.logger.debug(
|
||
`[getRequiredDocumentTypes] CAR_BODY claim with other object detected. ` +
|
||
`Requiring only 7 damaged party documents for claim ${claimRequest._id}`,
|
||
);
|
||
// Only require damaged party documents (7 documents)
|
||
return allTypes.filter((type) => type.startsWith("damaged_"));
|
||
}
|
||
}
|
||
|
||
// Default: require all 12 documents
|
||
return allTypes;
|
||
}
|
||
|
||
async uploadRequiredDocument(
|
||
requestId: string,
|
||
documentType: ClaimRequiredDocumentType,
|
||
file: Express.Multer.File,
|
||
actor: any,
|
||
) {
|
||
try {
|
||
const claimRequest = await this.claimDbService.findOne(requestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
if (String(claimRequest.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException(
|
||
"You do not have access to this claim file",
|
||
);
|
||
}
|
||
|
||
if (
|
||
String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) ===
|
||
effectiveUserId
|
||
) {
|
||
throw new ForbiddenException(
|
||
"User access denied. You are the guilty party in the blame file.",
|
||
);
|
||
}
|
||
|
||
if (claimRequest.currentStep !== ClaimStepsEnum.UploadRequiredDocuments) {
|
||
throw new BadRequestException(
|
||
`Claim is not in the required documents upload step. Current step: ${claimRequest.currentStep}`,
|
||
);
|
||
}
|
||
|
||
if (!file) {
|
||
throw new BadRequestException("File is required");
|
||
}
|
||
|
||
// Check if this document type has already been uploaded
|
||
const existingDocuments =
|
||
await this.claimRequiredDocumentDbService.findAll({
|
||
claimId: new Types.ObjectId(requestId),
|
||
documentType: documentType,
|
||
});
|
||
|
||
// If document exists, delete the old one first (allow replacement)
|
||
if (existingDocuments.length > 0) {
|
||
for (const existingDoc of existingDocuments) {
|
||
// Delete the file from filesystem
|
||
try {
|
||
if (existingDoc.path && existsSync(existingDoc.path)) {
|
||
await unlink(existingDoc.path);
|
||
this.logger.log(
|
||
`Deleted old file: ${existingDoc.path} for document type ${documentType}`,
|
||
);
|
||
}
|
||
} catch (fileError) {
|
||
this.logger.warn(
|
||
`Failed to delete file ${existingDoc.path}: ${fileError.message}`,
|
||
);
|
||
// Continue even if file deletion fails
|
||
}
|
||
|
||
// Delete from the separate collection
|
||
await this.claimRequiredDocumentDbService.delete(
|
||
existingDoc._id.toString(),
|
||
);
|
||
// Remove from claim's requiredDocuments map
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$unset: {
|
||
[`requiredDocuments.${documentType}`]: "",
|
||
},
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Create the document record
|
||
const documentRecord = await this.claimRequiredDocumentDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: new Types.ObjectId(requestId),
|
||
documentType: documentType,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
// Add document ID to claim's requiredDocuments map using documentType as key
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: {
|
||
[`requiredDocuments.${documentType}`]: documentRecord._id,
|
||
},
|
||
},
|
||
);
|
||
|
||
// Check if all required documents have been uploaded
|
||
// For CAR_BODY with other object, only require 7 documents instead of 12
|
||
const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest);
|
||
const uploadedDocuments =
|
||
await this.claimRequiredDocumentDbService.findAll({
|
||
claimId: new Types.ObjectId(requestId),
|
||
});
|
||
|
||
const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType);
|
||
const allUploaded = allRequiredTypes.every((type) =>
|
||
uploadedTypes.includes(type),
|
||
);
|
||
|
||
// If all documents are uploaded, move to next step
|
||
if (allUploaded) {
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
nextStep: ClaimStepsEnum.waitForDamageExpertComment,
|
||
claimStatus: ReqClaimStatus.UnChecked,
|
||
},
|
||
$push: {
|
||
steps: ClaimStepsEnum.UploadRequiredDocuments,
|
||
},
|
||
});
|
||
} else {
|
||
// Ensure status is set when entering this step (first document upload)
|
||
const currentClaim = await this.claimDbService.findOne(requestId);
|
||
if (
|
||
currentClaim?.claimStatus !==
|
||
ReqClaimStatus.UploadingRequiredDocuments
|
||
) {
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: {
|
||
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
nextStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
claimStatus: ReqClaimStatus.UploadingRequiredDocuments,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
message: "Document uploaded successfully",
|
||
documentId: documentRecord._id,
|
||
documentType: documentType,
|
||
allDocumentsUploaded: allUploaded,
|
||
nextStep: allUploaded
|
||
? ClaimStepsEnum.waitForDamageExpertComment
|
||
: null,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in uploadRequiredDocument: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getRequiredDocumentsStatus(requestId: string) {
|
||
try {
|
||
const claimRequest = await this.claimDbService.findOne(requestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Get required document types based on claim type
|
||
const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest);
|
||
const uploadedDocuments =
|
||
await this.claimRequiredDocumentDbService.findAll({
|
||
claimId: new Types.ObjectId(requestId),
|
||
});
|
||
|
||
const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType);
|
||
const status = allRequiredTypes.map((type) => {
|
||
const doc = uploadedDocuments.find((d) => d.documentType === type);
|
||
return {
|
||
documentType: type,
|
||
uploaded: !!doc,
|
||
documentId: doc?._id || null,
|
||
fileName: doc?.fileName || null,
|
||
uploadedAt: doc?.uploadedAt || null,
|
||
};
|
||
});
|
||
|
||
return {
|
||
claimId: requestId,
|
||
currentStep: claimRequest.currentStep,
|
||
nextStep: claimRequest.nextStep,
|
||
allUploaded: allRequiredTypes.every((type) =>
|
||
uploadedTypes.includes(type),
|
||
),
|
||
documents: status,
|
||
totalRequired: allRequiredTypes.length,
|
||
totalUploaded: uploadedDocuments.length,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in getRequiredDocumentsStatus: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getImageRequiredList(requestId: string) {
|
||
return await new Promise((resolve, reject) => {
|
||
this.claimDbService
|
||
.findOne(requestId)
|
||
.then((cl) => {
|
||
if (!cl) {
|
||
throw new HttpException(
|
||
"Claim File Not Found",
|
||
HttpStatus.NOT_FOUND,
|
||
);
|
||
}
|
||
|
||
// Allow access if:
|
||
// 1. Currently in ImageRequired step (currentStep or nextStep is ImageRequired)
|
||
// 2. OR has completed ImageRequired and moved to next step (to allow viewing data after completion)
|
||
const isInImageRequiredStep =
|
||
cl.currentStep === ClaimStepsEnum.ImageRequired ||
|
||
cl.nextStep === ClaimStepsEnum.ImageRequired;
|
||
|
||
const hasCompletedImageRequired =
|
||
cl.steps &&
|
||
Array.isArray(cl.steps) &&
|
||
cl.steps.includes(ClaimStepsEnum.ImageRequired);
|
||
|
||
if (isInImageRequiredStep || hasCompletedImageRequired) {
|
||
this.logger.debug(
|
||
`[getImageRequiredList] Allowing access for claim ${requestId}. ` +
|
||
`currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` +
|
||
`hasCompletedImageRequired: ${hasCompletedImageRequired}`,
|
||
);
|
||
if (
|
||
cl.imageRequired.aroundTheCar &&
|
||
Array.isArray(cl.imageRequired.aroundTheCar)
|
||
) {
|
||
cl.imageRequired.aroundTheCar.forEach((carPart) => {
|
||
//@ts-ignore
|
||
delete carPart?.aiReport;
|
||
});
|
||
cl.imageRequired.selectPartOfCar.forEach((part) => {
|
||
delete part.aiReport;
|
||
});
|
||
}
|
||
resolve(new ImageRequiredDto(cl));
|
||
} else {
|
||
this.logger.warn(
|
||
`[getImageRequiredList] Access denied for claim ${requestId}. ` +
|
||
`currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` +
|
||
`steps: ${JSON.stringify(cl.steps)}`,
|
||
);
|
||
throw new HttpException(
|
||
"This File is Not in ImageRequired Step",
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
}
|
||
})
|
||
.catch((er) => {
|
||
this.logger.error(er);
|
||
if (er instanceof Error) {
|
||
reject(er);
|
||
} else {
|
||
reject(new Error(`Promise rejected with non-error value: ${er}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async setImageRequiredList(
|
||
requestId: string,
|
||
partOfDamageId: string,
|
||
fileDetail: any,
|
||
) {
|
||
try {
|
||
const document = await this.claimDbService.findOneDocument(requestId);
|
||
|
||
if (!document) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
|
||
let result = [];
|
||
const imageRequiredKeys = Object.keys(document.imageRequired);
|
||
|
||
if (partOfDamageId === undefined) {
|
||
if (document.videoCaptureId) {
|
||
throw new HttpException("Video already exists", HttpStatus.CONFLICT);
|
||
}
|
||
|
||
const videoId = await this.createVideoCapture(fileDetail, requestId);
|
||
await this.updateClaimWithVideoCapture(requestId, videoId);
|
||
throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED);
|
||
} else if (partOfDamageId) {
|
||
await this.processDamageImages(
|
||
document,
|
||
requestId,
|
||
partOfDamageId,
|
||
fileDetail,
|
||
imageRequiredKeys,
|
||
result,
|
||
);
|
||
result.forEach((item) => {
|
||
if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) {
|
||
item.aroundTheCar.forEach((carPart) => {
|
||
delete carPart.aiReport;
|
||
});
|
||
item.selectPartOfCar.forEach((part) => {
|
||
delete part.aiReport;
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
|
||
async setDamageImage(
|
||
requestId: string,
|
||
partOfDamageId: string,
|
||
fileDetail: Express.Multer.File,
|
||
) {
|
||
try {
|
||
const document = await this.claimDbService.findOneDocument(requestId);
|
||
if (!document) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
if (!fileDetail) {
|
||
throw new BadRequestException("Image file is required.");
|
||
}
|
||
|
||
if (
|
||
document.currentStep !== ClaimStepsEnum.ImageRequired &&
|
||
document.nextStep !== ClaimStepsEnum.ImageRequired
|
||
) {
|
||
throw new BadRequestException(
|
||
`Claim is not in the ImageRequired step. Current step: ${document.currentStep}, Status: ${document.claimStatus}`,
|
||
);
|
||
}
|
||
|
||
const result = [];
|
||
const imageRequiredKeys = Object.keys(document.imageRequired);
|
||
|
||
// Process images - AI will be processed in background
|
||
await this.processDamageImages(
|
||
document,
|
||
requestId,
|
||
partOfDamageId,
|
||
fileDetail,
|
||
imageRequiredKeys,
|
||
result,
|
||
);
|
||
|
||
// Clean up the response - remove AI reports since they're processing in background
|
||
result.forEach((item) => {
|
||
if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) {
|
||
item.aroundTheCar.forEach((carPart) => delete carPart.aiReport);
|
||
item.selectPartOfCar.forEach((part) => delete part.aiReport);
|
||
}
|
||
});
|
||
|
||
this.logger.log(
|
||
`[setDamageImage] Image uploaded successfully for part ${partOfDamageId}, request ${requestId}. AI processing started in background.`,
|
||
);
|
||
|
||
return result;
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
|
||
async setVideoCapture(requestId: string, fileDetail: Express.Multer.File) {
|
||
try {
|
||
const document = await this.claimDbService.findOneDocument(requestId);
|
||
if (!document) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
if (!fileDetail) {
|
||
throw new BadRequestException("Video file is required.");
|
||
}
|
||
if (document.videoCaptureId) {
|
||
throw new HttpException(
|
||
"A video has already been uploaded for this claim.",
|
||
HttpStatus.CONFLICT,
|
||
);
|
||
}
|
||
|
||
const videoId = await this.createVideoCapture(fileDetail, requestId);
|
||
await this.updateClaimWithVideoCapture(requestId, videoId);
|
||
|
||
return {
|
||
statusCode: HttpStatus.ACCEPTED,
|
||
message: "Video capture uploaded successfully.",
|
||
videoId: videoId,
|
||
};
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
|
||
private async createVideoCapture(fileDetail: any, requestId: string) {
|
||
const videoId = await this.videoCaptureDbService.create({
|
||
path: fileDetail.path,
|
||
filName: fileDetail.filename,
|
||
claimId: new Types.ObjectId(requestId),
|
||
});
|
||
return videoId;
|
||
}
|
||
|
||
private async updateClaimWithVideoCapture(requestId: string, videoId: any) {
|
||
if (
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: { videoCaptureId: videoId },
|
||
},
|
||
)
|
||
) {
|
||
throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED);
|
||
}
|
||
}
|
||
|
||
private async processAiRequestQueue(
|
||
tasks: (() => Promise<void>)[],
|
||
delayMs = 200,
|
||
): Promise<void> {
|
||
for (const task of tasks) {
|
||
await task();
|
||
await this.delay(delayMs);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Process AI requests in the background without blocking the response
|
||
* This allows users to upload images quickly without waiting for AI processing
|
||
*/
|
||
private async processAiRequestQueueInBackground(
|
||
tasks: (() => Promise<void>)[],
|
||
requestId: string,
|
||
partId: string,
|
||
): Promise<void> {
|
||
if (tasks.length === 0) {
|
||
return;
|
||
}
|
||
|
||
this.logger.log(
|
||
`[BACKGROUND AI] Starting background AI processing for ${tasks.length} image(s), part ${partId}, request ${requestId}`,
|
||
);
|
||
|
||
// Process in background without blocking
|
||
setImmediate(async () => {
|
||
try {
|
||
await this.processAiRequestQueue(tasks);
|
||
this.logger.log(
|
||
`[BACKGROUND AI] Completed AI processing for part ${partId}, request ${requestId}`,
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[BACKGROUND AI] Failed to process AI requests for part ${partId}, request ${requestId}: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
// Don't throw - we're in background processing
|
||
}
|
||
});
|
||
}
|
||
|
||
private async handleAiSuccess(
|
||
response: any,
|
||
key: string,
|
||
partId: string,
|
||
requestId: string,
|
||
): Promise<void> {
|
||
this.logger.log(
|
||
`[STEP 4/4] Processing AI response for part ${partId}, requestId: ${requestId}`,
|
||
);
|
||
|
||
// 1. Validate that we have the processed image
|
||
if (!response?.downloadLink) {
|
||
this.logger.error(
|
||
`[ERROR] Missing processed image (downloadLink) in AI response`,
|
||
);
|
||
this.logger.error(`[ERROR] Part ID: ${partId}, Request ID: ${requestId}`);
|
||
this.logger.error(
|
||
`[ERROR] Response keys: ${JSON.stringify(response ? Object.keys(response) : "null")}`,
|
||
);
|
||
this.logger.error(
|
||
`[ERROR] Full response: ${JSON.stringify(response, null, 2)}`,
|
||
);
|
||
throw new HttpException(
|
||
`AI response missing processed image (downloadLink) for part ${partId}`,
|
||
HttpStatus.BAD_GATEWAY,
|
||
);
|
||
}
|
||
|
||
this.logger.log(`[STEP 4/4] Processed image URL: ${response.downloadLink}`);
|
||
|
||
// 2. Safely access the nested properties.
|
||
const reports = response?.reports;
|
||
const reportText = reports?.distinct_damaged_parts_report?.report;
|
||
|
||
// 3. Log warning if reports are missing but continue with image
|
||
if (!reports) {
|
||
this.logger.warn(
|
||
`[WARNING] AI response for part ${partId} is missing the 'reports' object, but downloadLink exists.`,
|
||
);
|
||
this.logger.warn(`[WARNING] Will save image but without AI report data.`);
|
||
} else {
|
||
this.logger.log(
|
||
`[STEP 4/4] AI reports present: ${reports ? "yes" : "no"}`,
|
||
);
|
||
}
|
||
|
||
// 4. Build the update payload.
|
||
const updatePayload = {
|
||
$set: {
|
||
[`imageRequired.${key}.$[elem].aiReport`]: reports || {},
|
||
[`imageRequired.${key}.$[elem].aiImage`]: response.downloadLink,
|
||
[`imageRequired.${key}.$[elem].aiReportText`]: reportText || "",
|
||
[`imageRequired.aiReportText`]: response.reportsText || "",
|
||
},
|
||
};
|
||
|
||
// 5. Update the database using arrayFilters.
|
||
try {
|
||
const updateResult = await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
updatePayload,
|
||
{
|
||
arrayFilters: [{ "elem.partId": partId }],
|
||
},
|
||
);
|
||
|
||
if (!updateResult) {
|
||
this.logger.error(
|
||
`[ERROR] Database update failed - no document found or update failed`,
|
||
);
|
||
this.logger.error(
|
||
`[ERROR] Request ID: ${requestId}, Part ID: ${partId}, Key: ${key}`,
|
||
);
|
||
throw new HttpException(
|
||
`Failed to update database with AI results for part ${partId}`,
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
|
||
this.logger.log(
|
||
`[SUCCESS] Successfully updated AI report and image for part ${partId}`,
|
||
);
|
||
} catch (dbError) {
|
||
this.logger.error(`[ERROR] Database update error for part ${partId}:`);
|
||
this.logger.error(`[ERROR] ${dbError.message}`);
|
||
this.logger.error(`[ERROR] Stack: ${dbError.stack}`);
|
||
throw new HttpException(
|
||
`Database update failed: ${dbError.message}`,
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async retryAiRequest(
|
||
newImageDoc: any,
|
||
key: string,
|
||
partId: string,
|
||
requestId: string,
|
||
retries = 3,
|
||
): Promise<void> {
|
||
const attemptNumber = 4 - retries; // Track which attempt this is
|
||
this.logger.log(
|
||
`[ATTEMPT ${attemptNumber}] Processing AI request for part ${partId}, requestId: ${requestId}`,
|
||
);
|
||
this.logger.log(
|
||
`[ATTEMPT ${attemptNumber}] File path: ${newImageDoc.path}, File name: ${newImageDoc.fileName}`,
|
||
);
|
||
|
||
try {
|
||
// Use the new, robust AiService to send the request.
|
||
// Pass both path and fileName to the AI service
|
||
const response = await this.aiService.aiRequestImage({
|
||
path: newImageDoc.path,
|
||
fileName: newImageDoc.fileName,
|
||
});
|
||
|
||
// If the request succeeds, handle the success.
|
||
await this.handleAiSuccess(response, key, partId, requestId);
|
||
this.logger.log(
|
||
`[SUCCESS] AI processing completed successfully for part ${partId}`,
|
||
);
|
||
} catch (error) {
|
||
// Extract error source from error message if available
|
||
const errorMessage = error.message || String(error);
|
||
const errorSource = errorMessage.includes("[ERROR]")
|
||
? errorMessage.split("[ERROR]")[1]?.split("]")[0] || "UNKNOWN"
|
||
: "UNKNOWN";
|
||
|
||
this.logger.error(
|
||
`[ATTEMPT ${attemptNumber} FAILED] Error source: ${errorSource}`,
|
||
);
|
||
this.logger.error(
|
||
`[ATTEMPT ${attemptNumber} FAILED] Part ID: ${partId}, Request ID: ${requestId}`,
|
||
);
|
||
this.logger.error(
|
||
`[ATTEMPT ${attemptNumber} FAILED] Error: ${errorMessage}`,
|
||
);
|
||
|
||
if (retries > 0) {
|
||
this.logger.warn(
|
||
`⚠️ Retrying AI request for part ${partId}... Attempts left: ${retries - 1}`,
|
||
);
|
||
await this.delay(1000); // Wait before retrying
|
||
return this.retryAiRequest(
|
||
newImageDoc,
|
||
key,
|
||
partId,
|
||
requestId,
|
||
retries - 1,
|
||
);
|
||
} else {
|
||
this.logger.error(
|
||
`🔴 [FINAL FAILURE] AI request failed after all retries`,
|
||
);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Request ID: ${requestId}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Part ID: ${partId}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] File path: ${newImageDoc.path}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Error source: ${errorSource}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Final error: ${errorMessage}`);
|
||
if (error.stack) {
|
||
this.logger.error(`🔴 [FINAL FAILURE] Stack trace: ${error.stack}`);
|
||
}
|
||
// Optional: Update the claim status to indicate an AI failure.
|
||
// You could throw here if you want to fail the entire operation
|
||
throw error; // Re-throw to propagate the error
|
||
}
|
||
}
|
||
}
|
||
|
||
private async processDamageImages(
|
||
document: any,
|
||
requestId: string,
|
||
partOfDamageId: string,
|
||
fileDetail: any,
|
||
imageRequiredKeys: string[],
|
||
result: any[],
|
||
) {
|
||
const aiRequestQueue = [];
|
||
|
||
for (const key of imageRequiredKeys) {
|
||
const images = document.imageRequired[key];
|
||
for (const img of images) {
|
||
if (img.partId === partOfDamageId) {
|
||
const newImageDoc = await this.createDamageImage(
|
||
fileDetail,
|
||
requestId,
|
||
);
|
||
|
||
// Queue AI request for background processing
|
||
const aiRequestTask = () =>
|
||
this.retryAiRequest(newImageDoc, key, partOfDamageId, requestId);
|
||
|
||
aiRequestQueue.push(aiRequestTask);
|
||
|
||
// Update the document immediately with the uploaded image
|
||
await this.updateImageRequiredDocument(
|
||
requestId,
|
||
key,
|
||
partOfDamageId,
|
||
newImageDoc,
|
||
);
|
||
const updatedDocs =
|
||
await this.claimDbService.findOneDocument(requestId);
|
||
this.handleImageUpdateResult(result, updatedDocs, requestId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process AI requests in the background (fire and forget)
|
||
// Don't await - let it run asynchronously so user doesn't have to wait
|
||
this.processAiRequestQueueInBackground(
|
||
aiRequestQueue,
|
||
requestId,
|
||
partOfDamageId,
|
||
).catch((error) => {
|
||
// Log errors but don't fail the upload
|
||
this.logger.error(
|
||
`[BACKGROUND AI] Error processing AI requests for part ${partOfDamageId}, request ${requestId}: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
});
|
||
}
|
||
|
||
private async updateImageRequiredDocument(
|
||
requestId,
|
||
key,
|
||
partOfDamageId,
|
||
newImageDoc,
|
||
) {
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: {
|
||
[`imageRequired.${key}.$[elem].upload`]: true,
|
||
[`imageRequired.${key}.$[elem].imageId`]: newImageDoc._id,
|
||
},
|
||
},
|
||
{
|
||
arrayFilters: [{ "elem.partId": partOfDamageId }],
|
||
},
|
||
);
|
||
}
|
||
|
||
private async createDamageImage(fileDetail, requestId) {
|
||
return await this.damageImageDbService.create({
|
||
path: fileDetail.path,
|
||
fileName: fileDetail.filename,
|
||
claimId: requestId,
|
||
});
|
||
}
|
||
|
||
private async handleImageUpdateResult(result, updatedDocs, requestId) {
|
||
const aroundCarChecked = updatedDocs.imageRequired.aroundTheCar.filter(
|
||
(r) => !r.upload,
|
||
);
|
||
const selectPartOfCar = updatedDocs.imageRequired.selectPartOfCar.filter(
|
||
(r) => !r.upload,
|
||
);
|
||
|
||
result.push(updatedDocs.imageRequired);
|
||
if (aroundCarChecked.length === 0 && selectPartOfCar.length === 0) {
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$push: {
|
||
steps: ClaimStepsEnum.ImageRequired,
|
||
},
|
||
$set: {
|
||
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
nextStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
claimStatus: ReqClaimStatus.UploadingRequiredDocuments,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
async myRequests(currentUser) {
|
||
// For users, return their own claim files
|
||
if (currentUser.role === RoleEnum.USER) {
|
||
const claimFile = await this.claimDbService.findAllByAnyFilter({
|
||
userId: new Types.ObjectId(currentUser.sub),
|
||
});
|
||
if (!claimFile) throw new NotFoundException("claim file not found");
|
||
return new MyRequestsDto(claimFile);
|
||
}
|
||
|
||
// For experts, return IN_PERSON claim files they initiated
|
||
const expertRoles = [
|
||
RoleEnum.EXPERT,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
RoleEnum.FIELD_EXPERT,
|
||
];
|
||
if (expertRoles.includes(currentUser.role)) {
|
||
const allClaims = await this.claimDbService.findAllByAnyFilter({});
|
||
const expertClaims = allClaims.filter((claim) => {
|
||
const blameFile = claim.blameFile;
|
||
return (
|
||
blameFile?.expertInitiated &&
|
||
blameFile?.creationMethod === "IN_PERSON" &&
|
||
String(blameFile.initiatedBy) === currentUser.sub
|
||
);
|
||
});
|
||
if (expertClaims.length === 0) {
|
||
throw new NotFoundException("No IN_PERSON claim files found");
|
||
}
|
||
return new MyRequestsDto(expertClaims);
|
||
}
|
||
|
||
throw new ForbiddenException("Invalid role");
|
||
}
|
||
|
||
private waitingForUserResendRS(req: ClaimRequestManagementModel) {
|
||
if (req.damageExpertResend) {
|
||
return new ClaimRequestDtoRs(
|
||
req.damageExpertResend as any,
|
||
"این درخواست نیاز به ارسال مدارک مجدد دارد ",
|
||
ReqClaimStatus.WaitingForUserToResend,
|
||
);
|
||
} else {
|
||
throw new ForbiddenException("damageExpertResend doesnt exist");
|
||
}
|
||
}
|
||
|
||
private closeRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست بسته شده است ",
|
||
ReqClaimStatus.CloseRequest,
|
||
);
|
||
}
|
||
|
||
private checkAgainRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"در انتظار پاسخ مجدد کارشناس",
|
||
ReqClaimStatus.CheckAgain,
|
||
);
|
||
}
|
||
|
||
private userPendingRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این پرونده از طرف شما کامل نشده میباشد",
|
||
ReqClaimStatus.UserPending,
|
||
);
|
||
}
|
||
|
||
private reviewRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
`درخواست درحال برسی توسط کارشناس میباشد ${req.unlockTime} - زمان باقی مانده لطفا صبر کنید`,
|
||
ReqClaimStatus.ReviewRequest,
|
||
);
|
||
}
|
||
|
||
private checkedRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست بررسی شده است",
|
||
ReqClaimStatus.CheckedRequest,
|
||
);
|
||
}
|
||
|
||
private uncheckedRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست در انتظار بررسی می باشد.",
|
||
ReqClaimStatus.UnChecked,
|
||
);
|
||
}
|
||
|
||
private waitingForUserCompletedRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست نیازمند تکمیل توسط کاربر می باشد.",
|
||
ReqClaimStatus.WaitingForUserCompleted,
|
||
);
|
||
}
|
||
|
||
private inPersonVisitRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"مراجعه حضوری",
|
||
ReqClaimStatus.InPersonVisit,
|
||
);
|
||
}
|
||
|
||
private responseController(request: ClaimRequestManagementModel) {
|
||
switch (request.claimStatus) {
|
||
case ReqClaimStatus.WaitingForUserCompleted:
|
||
return this.waitingForUserCompletedRS(request);
|
||
case ReqClaimStatus.UnChecked:
|
||
return this.uncheckedRequestRS(request);
|
||
case ReqClaimStatus.CheckedRequest:
|
||
return this.checkedRequestRS(request);
|
||
case ReqClaimStatus.ReviewRequest:
|
||
return this.reviewRequestRS(request);
|
||
case ReqClaimStatus.UserPending:
|
||
return this.userPendingRS(request);
|
||
case ReqClaimStatus.CloseRequest:
|
||
return this.closeRequestRS(request);
|
||
case ReqClaimStatus.WaitingForUserToResend:
|
||
return this.waitingForUserResendRS(request);
|
||
case ReqClaimStatus.CheckAgain:
|
||
return this.checkAgainRS(request);
|
||
case ReqClaimStatus.InPersonVisit:
|
||
return this.inPersonVisitRS(request);
|
||
case ReqClaimStatus.PendingFactorValidation:
|
||
return this.pendingFactorValidationRS(request);
|
||
case ReqClaimStatus.FactorRejected:
|
||
return this.factorRejectedRS(request);
|
||
case ReqClaimStatus.PendingFactorUpload:
|
||
return this.pendingFactorUploadRS(request);
|
||
case ReqClaimStatus.UploadingRequiredDocuments:
|
||
return this.uploadingRequiredDocumentsRS(request);
|
||
default:
|
||
return "Unknown status";
|
||
}
|
||
}
|
||
|
||
private pendingFactorUploadRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"منتظر آپلود فاکتور توسط کاربر",
|
||
ReqClaimStatus.PendingFactorUpload,
|
||
);
|
||
}
|
||
|
||
private pendingFactorValidationRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"منتظر تایید فاکتور توسط کارشناس",
|
||
ReqClaimStatus.PendingFactorValidation,
|
||
);
|
||
}
|
||
|
||
private factorRejectedRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"فاکتور ریجکت شده و باید دوباره ارسال شود",
|
||
ReqClaimStatus.FactorRejected,
|
||
);
|
||
}
|
||
|
||
private uploadingRequiredDocumentsRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"منتظر آپلود مدارک مورد نیاز توسط کاربر",
|
||
ReqClaimStatus.UploadingRequiredDocuments,
|
||
);
|
||
}
|
||
|
||
async requestDetails(requestId: string, user?: any) {
|
||
const request = await this.claimDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
// Verify access if user is provided
|
||
if (user) {
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, user);
|
||
if (String(request.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException(
|
||
"You do not have access to this claim file",
|
||
);
|
||
}
|
||
}
|
||
|
||
if (request.imageRequired) {
|
||
request.imageRequired.selectPartOfCar.forEach((r) => {
|
||
delete r.aiReport;
|
||
});
|
||
|
||
request.imageRequired.aroundTheCar.forEach((r) => {
|
||
//@ts-ignore
|
||
delete r.aiReport;
|
||
});
|
||
}
|
||
|
||
// Populate factorLink ObjectIds with actual file URLs
|
||
await this.populateFactorLinks(request.damageExpertReply);
|
||
await this.populateFactorLinks(request.damageExpertReplyFinal);
|
||
|
||
return this.responseController(request);
|
||
}
|
||
|
||
/**
|
||
* Converts factorLink ObjectIds to file URLs
|
||
*/
|
||
private async populateFactorLinks(reply: any): Promise<void> {
|
||
if (!reply || !Array.isArray(reply.parts)) {
|
||
return;
|
||
}
|
||
|
||
for (const part of reply.parts) {
|
||
if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) {
|
||
const factorDoc = await this.claimFactorsImageDbService.findById(
|
||
part.factorLink.toString(),
|
||
);
|
||
|
||
if (factorDoc) {
|
||
part.factorLink = buildFileLink(factorDoc.path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 response mapper:
|
||
* - convert factorLink ObjectId -> public URL
|
||
* - enrich daghi.branchId with branchName when available
|
||
*/
|
||
private async mapEvaluationForClient(
|
||
evaluation: any,
|
||
carType?: ClaimVehicleTypeV2,
|
||
): Promise<any | undefined> {
|
||
if (!evaluation) return undefined;
|
||
|
||
const mapReply = async (reply: any) => {
|
||
if (!reply) return reply;
|
||
|
||
const mapped = {
|
||
...(typeof reply?.toObject === "function" ? reply.toObject() : reply),
|
||
};
|
||
const parts = Array.isArray(mapped.parts) ? mapped.parts : [];
|
||
|
||
const factorIds = new Set<string>();
|
||
const branchIds = new Set<string>();
|
||
for (const p of parts) {
|
||
if (p?.factorLink && Types.ObjectId.isValid(String(p.factorLink))) {
|
||
factorIds.add(String(p.factorLink));
|
||
}
|
||
const daghi = p?.daghi;
|
||
const rawBranchId =
|
||
daghi && typeof daghi === "object"
|
||
? (daghi as any).branchId
|
||
: undefined;
|
||
if (rawBranchId && Types.ObjectId.isValid(String(rawBranchId))) {
|
||
branchIds.add(String(rawBranchId));
|
||
}
|
||
}
|
||
|
||
const factorMap = new Map<string, string>();
|
||
await Promise.all(
|
||
Array.from(factorIds).map(async (id) => {
|
||
const factorDoc = await this.claimFactorsImageDbService.findById(id);
|
||
if (factorDoc?.path) {
|
||
factorMap.set(id, buildFileLink(factorDoc.path));
|
||
}
|
||
}),
|
||
);
|
||
|
||
const branchMap = new Map<string, string>();
|
||
await Promise.all(
|
||
Array.from(branchIds).map(async (id) => {
|
||
const branch = await this.branchDbService.findById(id);
|
||
if (branch?.name) {
|
||
branchMap.set(id, branch.name);
|
||
}
|
||
}),
|
||
);
|
||
|
||
mapped.parts = parts.map((part: any) => {
|
||
const nextPart = { ...part };
|
||
|
||
if (
|
||
nextPart.factorLink &&
|
||
Types.ObjectId.isValid(String(nextPart.factorLink))
|
||
) {
|
||
const link = factorMap.get(String(nextPart.factorLink));
|
||
if (link) nextPart.factorLink = link;
|
||
}
|
||
|
||
const daghi = nextPart.daghi;
|
||
if (daghi && typeof daghi === "object" && !Array.isArray(daghi)) {
|
||
const branchId = (daghi as any).branchId;
|
||
const branchName =
|
||
branchId && Types.ObjectId.isValid(String(branchId))
|
||
? branchMap.get(String(branchId))
|
||
: undefined;
|
||
nextPart.daghi = {
|
||
...daghi,
|
||
...(branchName ? { branchName } : {}),
|
||
};
|
||
}
|
||
|
||
if (nextPart.carPartDamage != null) {
|
||
nextPart.carPartDamage = migrateExpertReplyCarPartDamageToUnified(
|
||
nextPart.carPartDamage,
|
||
carType,
|
||
);
|
||
}
|
||
|
||
return nextPart;
|
||
});
|
||
|
||
return mapped;
|
||
};
|
||
|
||
return {
|
||
...evaluation,
|
||
damageExpertReply: await mapReply(evaluation.damageExpertReply),
|
||
damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal),
|
||
};
|
||
}
|
||
|
||
// async submitUserReply(
|
||
// requestId: string,
|
||
// body: UserCommentDto,
|
||
// file?: Express.Multer.File,
|
||
// actor?: any,
|
||
// ) {
|
||
// try {
|
||
// const request = await this.claimDbService.findOne(requestId);
|
||
|
||
// if (!this.isValidRequest(request)) {
|
||
// throw new HttpException("Invalid request", HttpStatus.NOT_ACCEPTABLE);
|
||
// }
|
||
|
||
// // Verify access if actor is provided
|
||
// if (actor) {
|
||
// const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
// if (String(request.userId) !== effectiveUserId) {
|
||
// throw new ForbiddenException(
|
||
// "You do not have access to this claim file",
|
||
// );
|
||
// }
|
||
// }
|
||
|
||
// const finalReply =
|
||
// request.damageExpertReplyFinal || request.damageExpertReply;
|
||
|
||
// const replyFieldKey = request.damageExpertReplyFinal
|
||
// ? "damageExpertReplyFinal"
|
||
// : "damageExpertReply";
|
||
|
||
// const parts = finalReply?.parts || [];
|
||
// const missingFactors = parts
|
||
// .filter((p) => p.factorNeeded === true && !p.factorLink)
|
||
// .map(
|
||
// (p) => carPartDamageLabelForFactorRecord(p.carPartDamage) || p.partId,
|
||
// );
|
||
|
||
// if (missingFactors.length > 0) {
|
||
// throw new HttpException(
|
||
// `Missing factor images for parts: ${missingFactors.join(", ")}`,
|
||
// HttpStatus.BAD_REQUEST,
|
||
// );
|
||
// }
|
||
|
||
// const userComment = {
|
||
// ...body,
|
||
// ...(file ? { fileLink: buildFileLink(file.path) } : {}),
|
||
// };
|
||
|
||
// await this.claimDbService.findAndUpdate(requestId, {
|
||
// $set: {
|
||
// [`${replyFieldKey}.userComment`]: {
|
||
// ...userComment,
|
||
// time: Date.now(),
|
||
// },
|
||
// },
|
||
// });
|
||
|
||
// if (body.isAccept) {
|
||
// const { partsFactorDetail, partsNeedFactor } =
|
||
// await this.processAcceptance(request, file, requestId, body);
|
||
|
||
// await this.claimDbService.findAndUpdate(requestId, {
|
||
// $set: {
|
||
// claimStatus: ReqClaimStatus.CloseRequest,
|
||
// },
|
||
// });
|
||
|
||
// return new SubmitUserReplyDtoRs(
|
||
// request,
|
||
// partsFactorDetail,
|
||
// partsNeedFactor,
|
||
// );
|
||
// } else {
|
||
// this.handleRejection(request, body.isAccept, requestId);
|
||
// return { requestId, isAccept: body.isAccept };
|
||
// }
|
||
// } catch (error) {
|
||
// this.handleError(error);
|
||
// }
|
||
// }
|
||
|
||
private isValidRequest(request: any): boolean {
|
||
return (
|
||
request.damageExpertReply &&
|
||
request.claimStatus === ReqClaimStatus.CheckedRequest
|
||
);
|
||
}
|
||
|
||
// private async processAcceptance(
|
||
// request: any,
|
||
// file: any,
|
||
// requestId: string,
|
||
// body: UserCommentDto,
|
||
// ): Promise<{ partsFactorDetail: any[]; partsNeedFactor: boolean }> {
|
||
// const partsFactorDetail = [];
|
||
// let partsNeedFactor = false;
|
||
|
||
// for (let i = 0; i < request.damageExpertReply.parts.length; i++) {
|
||
// const part = request.damageExpertReply.parts[i];
|
||
|
||
// if (!part || part.partCost === undefined) continue;
|
||
|
||
// if (part.partCost === "FACTOR") {
|
||
// partsNeedFactor = await this.handleFactorPart(
|
||
// requestId,
|
||
// i,
|
||
// part,
|
||
// partsFactorDetail,
|
||
// partsNeedFactor,
|
||
// );
|
||
// } else {
|
||
// await this.handleNonFactorPart(requestId, file, body.isAccept, part);
|
||
// }
|
||
// }
|
||
|
||
// return { partsFactorDetail, partsNeedFactor };
|
||
// }
|
||
|
||
// private async handleFactorPart(
|
||
// requestId: string,
|
||
// i: number,
|
||
// part: any,
|
||
// partsFactorDetail: any[],
|
||
// partsNeedFactor: boolean,
|
||
// ): Promise<boolean> {
|
||
// if (part.needFactor === undefined) {
|
||
// const uId = uuidv4();
|
||
// partsNeedFactor = true;
|
||
|
||
// await this.claimDbService.findAndUpdate(requestId, {
|
||
// $set: {
|
||
// [`damageExpertReply.parts.${i}.needFactor`]: true,
|
||
// [`damageExpertReply.parts.${i}.partId`]: uId,
|
||
// },
|
||
// });
|
||
|
||
// partsFactorDetail.push({ partId: uId, ...part });
|
||
// } else {
|
||
// partsFactorDetail.push(part);
|
||
// }
|
||
|
||
// return partsNeedFactor;
|
||
// }
|
||
|
||
// private async handleNonFactorPart(
|
||
// requestId: string,
|
||
// file: any,
|
||
// isAccept: boolean,
|
||
// part: any,
|
||
// ): Promise<void> {
|
||
// const sign = await this.claimSignDbService.create(file);
|
||
|
||
// await this.claimDbService.findAndUpdate(requestId, {
|
||
// $set: {
|
||
// "damageExpertReply.userComment.isAccept": isAccept,
|
||
// "damageExpertReply.userComment.signId": sign["_id"],
|
||
// },
|
||
// });
|
||
// }
|
||
|
||
private async handleRejection(
|
||
request: any,
|
||
isAccept: boolean,
|
||
requestId: string,
|
||
): Promise<void> {
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
"damageExpertReply.userComment.isAccept": isAccept,
|
||
"damageExpertReply.userComment.signId": null,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Allows the damaged user to rate their claim file after completion.
|
||
* Only the claim owner (damaged user) can submit this rating and only
|
||
* when the claim is in CloseRequest status.
|
||
*/
|
||
async addUserRating(
|
||
claimRequestId: string,
|
||
actor: any,
|
||
ratingDto: UserRatingDto,
|
||
): Promise<UserClaimRating> {
|
||
const claim = await this.claimDbService.findOne(claimRequestId);
|
||
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Only the damaged user (claim owner) can rate the claim
|
||
const actorId = actor?.sub;
|
||
if (!actorId || String(claim.userId) !== actorId) {
|
||
throw new ForbiddenException(
|
||
"Only the claim owner can submit a satisfaction rating.",
|
||
);
|
||
}
|
||
|
||
// Rating is only allowed after the claim is fully closed
|
||
if (claim.claimStatus !== ReqClaimStatus.CloseRequest) {
|
||
throw new BadRequestException(
|
||
"You can only rate a claim after it has been closed.",
|
||
);
|
||
}
|
||
|
||
const userRating: UserClaimRating = {
|
||
progressSpeed: ratingDto.progressSpeed,
|
||
registrationEase: ratingDto.registrationEase,
|
||
overallEvaluation: ratingDto.overallEvaluation,
|
||
comment: ratingDto.comment,
|
||
createdAt: new Date(),
|
||
};
|
||
|
||
await this.claimDbService.findAndUpdate(claimRequestId, {
|
||
$set: { userRating },
|
||
});
|
||
|
||
return userRating;
|
||
}
|
||
|
||
async uploadClaimFactor(
|
||
claimId: string,
|
||
partId: string,
|
||
file: Express.Multer.File,
|
||
actor: any,
|
||
) {
|
||
try {
|
||
if (!file) {
|
||
throw new BadRequestException("A factor file is required for upload.");
|
||
}
|
||
|
||
const claim = await this.claimRequestManagementDbService.findOne(claimId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(claimId, actor);
|
||
if (String(claim.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException(
|
||
"You do not have access to this claim file",
|
||
);
|
||
}
|
||
|
||
const finalReply =
|
||
claim.damageExpertReplyFinal || claim.damageExpertReply;
|
||
const replyFieldKey = claim.damageExpertReplyFinal
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
|
||
if (!finalReply) {
|
||
throw new BadRequestException("No expert reply found in this claim.");
|
||
}
|
||
|
||
const part = finalReply.parts?.find((p) => p.partId === String(partId));
|
||
if (!part) {
|
||
throw new BadRequestException(
|
||
"The specified part was not found in the expert's reply.",
|
||
);
|
||
}
|
||
|
||
const factorRecord = await this.claimFactorsImageDbService.create({
|
||
claimId,
|
||
partId,
|
||
partName: carPartDamageLabelForFactorRecord(part.carPartDamage),
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
const updatedClaim =
|
||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||
{
|
||
_id: new Types.ObjectId(claimId),
|
||
[`${replyFieldKey}.parts.partId`]: partId,
|
||
},
|
||
{
|
||
$set: {
|
||
[`${replyFieldKey}.parts.$.factorLink`]: factorRecord._id,
|
||
[`${replyFieldKey}.parts.$.factorStatus`]: FactorStatus.PENDING,
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new NotFoundException(
|
||
"Could not find the specific part to update in the database.",
|
||
);
|
||
}
|
||
|
||
await this.checkAndTransitionStatusAfterUpload(claimId);
|
||
|
||
return {
|
||
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||
factorId: factorRecord._id,
|
||
factorLink: buildFileLink(file.path),
|
||
url: buildFileLink(file.path),
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error during factor upload for claim ${claimId}:`,
|
||
error.stack,
|
||
);
|
||
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
|
||
throw new HttpException(
|
||
"An internal server error occurred during factor upload.",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Upload repair factor file for a part marked `factorNeeded` on the expert reply (ClaimCase).
|
||
* Same behavior as v1 {@link uploadClaimFactor} but uses `evaluation.damageExpertReply` / `damageExpertReplyFinal`.
|
||
*/
|
||
async uploadClaimFactorV2(
|
||
claimRequestId: string,
|
||
partId: string,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
) {
|
||
try {
|
||
if (!file) {
|
||
throw new BadRequestException("A factor file is required for upload.");
|
||
}
|
||
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim not found");
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claim,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claim,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"You do not have access to this claim file",
|
||
);
|
||
|
||
const replyKey = claim.evaluation?.damageExpertReplyFinal
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
const finalReply =
|
||
replyKey === "damageExpertReplyFinal"
|
||
? claim.evaluation?.damageExpertReplyFinal
|
||
: claim.evaluation?.damageExpertReply;
|
||
|
||
if (!finalReply?.parts?.length) {
|
||
throw new BadRequestException("No expert reply found in this claim.");
|
||
}
|
||
|
||
const part = finalReply.parts.find(
|
||
(p) => String(p.partId) === String(partId),
|
||
);
|
||
if (!part) {
|
||
throw new BadRequestException(
|
||
"The specified part was not found in the expert's reply.",
|
||
);
|
||
}
|
||
|
||
if (!part.factorNeeded) {
|
||
throw new BadRequestException(
|
||
"This part does not require a factor upload.",
|
||
);
|
||
}
|
||
|
||
if (part.factorLink) {
|
||
throw new ConflictException(
|
||
"A factor has already been uploaded for this part.",
|
||
);
|
||
}
|
||
|
||
if (!claimCaseStatusAllowsOwnerFactorUploadDuringRevision(claim.status)) {
|
||
throw new BadRequestException(
|
||
"Factors can only be uploaded while the claim is in a repair-factor upload phase (see ClaimCaseStatus).",
|
||
);
|
||
}
|
||
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
|
||
throw new BadRequestException(
|
||
"Factors can only be uploaded while the claim needs owner action (claimStatus NEEDS_REVISION).",
|
||
);
|
||
}
|
||
if (
|
||
claim.workflow?.currentStep !==
|
||
ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS
|
||
) {
|
||
throw new BadRequestException(
|
||
`Repair factors are uploaded at workflow step ${ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS} (after accepting priced lines when applicable). Current: ${String(claim.workflow?.currentStep ?? "")}.`,
|
||
);
|
||
}
|
||
|
||
const flowParts = finalReply.parts ?? [];
|
||
const pricingShape = classifyV2ExpertPricingParts(flowParts);
|
||
if (
|
||
pricingShape.mixedFactorAndPrice &&
|
||
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
|
||
) {
|
||
throw new BadRequestException(
|
||
"Sign acceptance of the priced repair lines first (`PUT .../owner-insurer-approval/sign` while claimStatus is NEEDS_REVISION at INSURER_REVIEW), then upload factors for factor-needed parts.",
|
||
);
|
||
}
|
||
|
||
const partNameForRecord =
|
||
carPartDamageLabelForFactorRecord(part.carPartDamage) ||
|
||
String(part.partId ?? "");
|
||
|
||
const factorRecord = await this.claimFactorsImageDbService.create({
|
||
claimId: new Types.ObjectId(claimRequestId),
|
||
partId: String(partId),
|
||
partName: partNameForRecord,
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
const pathPrefix = `evaluation.${replyKey}.parts`;
|
||
|
||
const updatedClaim = await this.claimCaseDbService.findOneAndUpdate(
|
||
{
|
||
_id: new Types.ObjectId(claimRequestId),
|
||
[`${pathPrefix}.partId`]: partId,
|
||
},
|
||
{
|
||
$set: {
|
||
[`${pathPrefix}.$.factorLink`]: factorRecord._id,
|
||
[`${pathPrefix}.$.factorStatus`]: FactorStatus.PENDING,
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new NotFoundException(
|
||
"Could not find the specific part to update in the database.",
|
||
);
|
||
}
|
||
|
||
await this.checkAndTransitionStatusAfterFactorUploadV2(claimRequestId);
|
||
|
||
return {
|
||
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||
factorId: factorRecord._id,
|
||
url: buildFileLink(file.path),
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error during V2 factor upload for claim ${claimRequestId}:`,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
|
||
throw new HttpException(
|
||
"An internal server error occurred during factor upload.",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* When every `factorNeeded` part on the active expert reply has a `factorLink`, move claim toward expert validation (v1: PendingFactorValidation).
|
||
*/
|
||
private async checkAndTransitionStatusAfterFactorUploadV2(
|
||
claimRequestId: string,
|
||
): Promise<void> {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim?.evaluation) {
|
||
return;
|
||
}
|
||
|
||
const finalReply =
|
||
claim.evaluation.damageExpertReplyFinal ||
|
||
claim.evaluation.damageExpertReply;
|
||
|
||
if (!finalReply?.parts) {
|
||
return;
|
||
}
|
||
|
||
const requiredFactorParts = finalReply.parts.filter(
|
||
(p) => p.factorNeeded === true,
|
||
);
|
||
if (requiredFactorParts.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const allFactorsAreUploaded = requiredFactorParts.every(
|
||
(p) => !!p.factorLink,
|
||
);
|
||
if (!allFactorsAreUploaded) {
|
||
return;
|
||
}
|
||
|
||
this.logger.log(
|
||
`All required factors for claim case ${claimRequestId} are uploaded (V2). Setting claim status for validation.`,
|
||
);
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
|
||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||
"workflow.nextStep": ClaimWorkflowStep.INSURER_REVIEW,
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
|
||
timestamp: new Date(),
|
||
metadata: { claimRequestId },
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
private async checkAndTransitionStatusAfterUpload(
|
||
claimId: string,
|
||
): Promise<void> {
|
||
// Re-fetch the latest state of the claim
|
||
const claim = await this.claimRequestManagementDbService.findOne(claimId);
|
||
const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply;
|
||
|
||
if (!finalReply?.parts) {
|
||
return;
|
||
}
|
||
|
||
const requiredFactorParts = finalReply.parts.filter(
|
||
(p) => p.factorNeeded === true,
|
||
);
|
||
|
||
if (requiredFactorParts.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const allFactorsAreUploaded = requiredFactorParts.every(
|
||
(p) => !!p.factorLink,
|
||
);
|
||
|
||
if (allFactorsAreUploaded) {
|
||
this.logger.log(
|
||
`All required factors for claim ${claimId} have been uploaded. Updating status.`,
|
||
);
|
||
await this.claimRequestManagementDbService.findByIdAndUpdate(claimId, {
|
||
$set: {
|
||
claimStatus: ReqClaimStatus.PendingFactorValidation,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
private async resendDocumentsController(claimFile, query, file) {
|
||
if (!Object.keys(query).includes("documentName")) {
|
||
throw new HttpException("use documentName", HttpStatus.BAD_REQUEST);
|
||
}
|
||
|
||
const fileRequired = claimFile.damageExpertResend.resendDocuments;
|
||
if (!fileRequired.includes(query.documentName)) {
|
||
throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST);
|
||
}
|
||
|
||
try {
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{
|
||
$push: {
|
||
"userResendDocuments.documents": {
|
||
[query.documentName]: buildFileLink(file.path),
|
||
},
|
||
},
|
||
},
|
||
{ new: true },
|
||
);
|
||
|
||
const existingKeys = updated.userResendDocuments.documents.flatMap(
|
||
Object.keys,
|
||
);
|
||
const allExist = fileRequired.every((key) => existingKeys.includes(key));
|
||
|
||
if (allExist) {
|
||
await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{ $set: { claimStatus: ReqClaimStatus.CheckAgain } },
|
||
);
|
||
}
|
||
|
||
return {
|
||
allExist,
|
||
updated: updated.userResendDocuments.documents,
|
||
fileRequired,
|
||
};
|
||
} catch (err) {
|
||
this.logger.error(err);
|
||
return new HttpException(err.message, err.status);
|
||
}
|
||
}
|
||
|
||
private async resendCarParts(claimFile, query, file) {
|
||
if (!Object.keys(query).includes("partId")) {
|
||
throw new HttpException(
|
||
"use resendCarParts query string",
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
if (!Object.keys(query).includes("side")) {
|
||
throw new HttpException(
|
||
"side is required for car parts",
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
const fileRequired = claimFile.damageExpertResend.resendCarParts.map(
|
||
(r) => r.partId,
|
||
);
|
||
if (!fileRequired.includes(query.partId)) {
|
||
throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST);
|
||
}
|
||
|
||
try {
|
||
const fileEntry = {
|
||
[query.partId]: {
|
||
link: buildFileLink(file.path),
|
||
side: query.side,
|
||
},
|
||
};
|
||
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{ $push: { "userResendDocuments.carParts": fileEntry } },
|
||
{ new: true },
|
||
);
|
||
|
||
const existingKeys = updated.userResendDocuments.carParts.flatMap(
|
||
Object.keys,
|
||
);
|
||
const allExist = fileRequired.every((key) => existingKeys.includes(key));
|
||
|
||
if (allExist) {
|
||
await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{ $set: { claimStatus: ReqClaimStatus.CheckAgain } },
|
||
);
|
||
}
|
||
|
||
return {
|
||
allExist,
|
||
updated: updated.userResendDocuments.carParts,
|
||
fileRequired,
|
||
};
|
||
} catch (err) {
|
||
this.logger.error(err);
|
||
return new HttpException(err.message, err.status);
|
||
}
|
||
}
|
||
|
||
async resendFiles(claimId, file, query, actor) {
|
||
let claimFile = await this.claimRequestManagementDbService.findOne(claimId);
|
||
if (!claimFile) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(claimId, actor);
|
||
if (String(claimFile.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
|
||
switch (query.fields) {
|
||
case "resendDocuments":
|
||
return this.resendDocumentsController(claimFile, query, file);
|
||
case "resendCarParts":
|
||
return this.resendCarParts(claimFile, query, file);
|
||
default:
|
||
return new HttpException("wrong query string", HttpStatus.BAD_REQUEST);
|
||
}
|
||
}
|
||
|
||
async handleUserObjectionAndParts(
|
||
claimRequestId: string,
|
||
userObjectionDto: UserObjectionDto,
|
||
) {
|
||
const claimRequest = await this.claimDbService.findOne(claimRequestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
if (
|
||
claimRequest.claimStatus !== ReqClaimStatus.WaitingForUserToResend &&
|
||
claimRequest.claimStatus !== ReqClaimStatus.CheckedRequest
|
||
) {
|
||
throw new BadRequestException(
|
||
"درخواست شما در این حالت نمی تواند انجام شود",
|
||
);
|
||
}
|
||
|
||
if (claimRequest.objection)
|
||
throw new BadRequestException("Objection already exists!");
|
||
|
||
const updatePayload: any = {
|
||
objection: userObjectionDto,
|
||
};
|
||
|
||
if (userObjectionDto.newParts?.length) {
|
||
const newPartsWithIds = userObjectionDto.newParts.map((part) => ({
|
||
...part,
|
||
partId: part.partId || new Types.ObjectId(),
|
||
}));
|
||
|
||
updatePayload["newParts"] = newPartsWithIds;
|
||
}
|
||
|
||
const updated = await this.claimDbService.findAndUpdate(
|
||
claimRequestId,
|
||
{
|
||
...updatePayload,
|
||
claimStatus: ReqClaimStatus.CheckAgain,
|
||
},
|
||
{ new: true },
|
||
);
|
||
|
||
return updated.objection;
|
||
}
|
||
|
||
async inPersonVisit(requestId: string, branchId: string, actorDetail: any) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Claim not found");
|
||
}
|
||
|
||
const branch = await this.branchDbService.findById(branchId);
|
||
if (!branch) {
|
||
throw new NotFoundException(`Branch with ID ${branchId} not found.`);
|
||
}
|
||
|
||
if (String(request.userClientKey) !== String(branch.clientKey)) {
|
||
throw new ForbiddenException(
|
||
"This branch does not belong to the insurer of this claim.",
|
||
);
|
||
}
|
||
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
requestId,
|
||
{
|
||
claimStatus: ReqClaimStatus.InPersonVisit,
|
||
visitLocation: `Branch ${branch.name} at ${branch.address}`,
|
||
},
|
||
);
|
||
|
||
if (
|
||
Types.ObjectId.isValid(String(actorDetail.sub)) &&
|
||
Types.ObjectId.isValid(String(request.userClientKey)) &&
|
||
Types.ObjectId.isValid(String(requestId))
|
||
) {
|
||
await this.expertFileActivityDbService.recordEvent({
|
||
expertId: String(actorDetail.sub),
|
||
tenantId: String(request.userClientKey),
|
||
fileId: String(requestId),
|
||
fileType: ExpertFileKind.CLAIM,
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${requestId}:handled:inperson:${actorDetail.sub}`,
|
||
});
|
||
}
|
||
|
||
return updated;
|
||
}
|
||
|
||
async retrieveInsuranceBranches(insuranceId: string) {
|
||
return await this.branchDbService.findAll(insuranceId);
|
||
}
|
||
|
||
private handleError(error) {
|
||
this.logger.error(error);
|
||
throw error;
|
||
}
|
||
|
||
/**
|
||
* Convert Gregorian date to Persian date format (1404/08/13)
|
||
*/
|
||
private convertToPersianDate(date: Date | string): string {
|
||
const d = new Date(date);
|
||
const persianDate = new Date(d).toLocaleDateString("fa-IR", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
});
|
||
// Convert Persian digits to English and format as YYYY/MM/DD
|
||
const persianToEnglish: { [key: string]: string } = {
|
||
"۰": "0",
|
||
"۱": "1",
|
||
"۲": "2",
|
||
"۳": "3",
|
||
"۴": "4",
|
||
"۵": "5",
|
||
"۶": "6",
|
||
"۷": "7",
|
||
"۸": "8",
|
||
"۹": "9",
|
||
};
|
||
return persianDate
|
||
.split("/")
|
||
.map((part) =>
|
||
part
|
||
.split("")
|
||
.map((char) => persianToEnglish[char] || char)
|
||
.join(""),
|
||
)
|
||
.join("/");
|
||
}
|
||
|
||
/**
|
||
* Get time in 24-hour format (HH:mm) from date
|
||
*/
|
||
private getTime24Hour(date: Date | string): string {
|
||
const d = new Date(date);
|
||
const hours = d.getHours().toString().padStart(2, "0");
|
||
const minutes = d.getMinutes().toString().padStart(2, "0");
|
||
return `${hours}:${minutes}`;
|
||
}
|
||
|
||
/**
|
||
* Call reverse geocoding API to get address from lat/lon
|
||
*/
|
||
private async getAddressFromCoordinates(
|
||
lat: number,
|
||
lon: number,
|
||
): Promise<string> {
|
||
try {
|
||
const response = await firstValueFrom(
|
||
this.httpService.get(
|
||
`https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`,
|
||
{
|
||
headers: {
|
||
accept: "application/json",
|
||
"x-api-key": this.MAP_IR_API_KEY,
|
||
},
|
||
},
|
||
),
|
||
);
|
||
return response.data?.address_compact || "";
|
||
} catch (error) {
|
||
this.logger.error("Failed to get address from coordinates", error);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate EstimateAmount from damageExpertReply parts
|
||
*/
|
||
private calculateEstimateAmount(parts: any[]): number {
|
||
if (!parts || !Array.isArray(parts) || parts.length === 0) {
|
||
return 0;
|
||
}
|
||
return parts.reduce((sum, part) => {
|
||
const totalPayment = part.totalPayment
|
||
? parseFloat(part.totalPayment.toString())
|
||
: 0;
|
||
return sum + (isNaN(totalPayment) ? 0 : totalPayment);
|
||
}, 0);
|
||
}
|
||
|
||
private normalizeFanavaranEstimateAmount(amount: number): number {
|
||
return amount > 0 ? amount : FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT;
|
||
}
|
||
|
||
/** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */
|
||
private collectBranchIdsFromClaimExpertReply(reply: {
|
||
parts?: unknown[];
|
||
}): Set<string> {
|
||
const ids = new Set<string>();
|
||
const parts = reply?.parts;
|
||
if (!Array.isArray(parts)) {
|
||
return ids;
|
||
}
|
||
for (const p of parts) {
|
||
const d = (p as { daghi?: unknown })?.daghi;
|
||
if (d && typeof d === "object" && d !== null && "branchId" in d) {
|
||
const bid = (d as { branchId?: unknown }).branchId;
|
||
if (bid != null && Types.ObjectId.isValid(String(bid))) {
|
||
ids.add(String(bid));
|
||
}
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
private getTejaratnoFanavaranConfig() {
|
||
return {
|
||
appName: this.APP_NAME,
|
||
secret: this.SECRET,
|
||
username: this.USERNAME,
|
||
password: this.PASSWORD,
|
||
corpId: this.CORP_ID,
|
||
contractId: this.CONTRACT_ID,
|
||
location: this.LOCATION,
|
||
};
|
||
}
|
||
|
||
private applyFanavaranDefaultFields(result: Record<string, unknown>): void {
|
||
result.ActualPremium = null;
|
||
result.ArchiveNo = null;
|
||
result.AuthorityCulpritId = null;
|
||
result.AccidentCulpritId = null;
|
||
result.ClaimCompletionDate = null;
|
||
result.CostSeparationToDmgSections = null;
|
||
result.CouponNo = null;
|
||
result.CourtArchiveNo = null;
|
||
result.CustomerFaultPercent = null;
|
||
result.CulpritLicenceCityId = null;
|
||
result.CulpritLicenceCountryId = null;
|
||
result.CulpritLicenceForeignCityName = null;
|
||
result.CulpritLicenceIssuDate = "1394/10/13";
|
||
result.CulpritLicenceNo = "1124242";
|
||
result.DamagedCount = 1;
|
||
result.DmgAssessorFirstCreationTime = null;
|
||
result.EntryDate = null;
|
||
result.GlassBreakReasonId = null;
|
||
result.IsLicenseMatchWithVehicleKind = 1;
|
||
result.HasOtherCulprit = 0;
|
||
result.IsAccidentOutOfBorder = 0;
|
||
result.IsFatalAccident = 0;
|
||
result.IsOwnerChanged = null;
|
||
result.IsPlaqueChanged = 0;
|
||
result.IsSurplusArticleEighthLaw = null;
|
||
result.PlaqueCityId = null;
|
||
result.PlaqueKindId = null;
|
||
result.PlaqueLeftNo = null;
|
||
result.PlaqueMiddleCodeId = null;
|
||
result.PlaqueNo = null;
|
||
result.PlaqueRightNo = null;
|
||
result.PlaqueSampleId = null;
|
||
result.PlaqueSerial = null;
|
||
result.PoliceOfficerId = 1;
|
||
result.PoliceReportDesc = null;
|
||
result.PoliceReportSeri = null;
|
||
result.PoliceReportSerial = null;
|
||
result.PolicyId = null;
|
||
result.PreviousPolicyEndDate = "";
|
||
result.StatusChangeDate = null;
|
||
result.TrackingCode = null;
|
||
result.IsLicenseReplacement = 0;
|
||
result.UnknownCulpritCauseId = null;
|
||
}
|
||
|
||
private async buildFanavaranSubmitPayload(input: {
|
||
accidentReason?: { id?: string | number | null; fanavaran?: number | null };
|
||
createdAt?: Date | string;
|
||
location?: { lat?: number; lon?: number };
|
||
damageParts?: any[];
|
||
resolvePolicyId: () => Promise<number | null>;
|
||
logPrefix: string;
|
||
defaults?: {
|
||
AccidentCityId: number;
|
||
AccidentReportTypeId: number;
|
||
AccidentVehicleUsedId: number;
|
||
ClaimExpertId: number;
|
||
CompensationReferenceId: number;
|
||
CulpritLicenceTypeId: number;
|
||
CulpritTypeId: number;
|
||
};
|
||
}): Promise<Record<string, unknown>> {
|
||
const lookupDefaults = input.defaults ?? this.TEJARATNO_FANAVARAN_DEFAULTS;
|
||
const result: Record<string, unknown> = {
|
||
...lookupDefaults,
|
||
AccidentCauseId: FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID,
|
||
};
|
||
|
||
if (!input.accidentReason) {
|
||
this.logger.error(
|
||
`${input.logPrefix} expert decision / accidentReason is missing`,
|
||
);
|
||
}
|
||
|
||
if (input.createdAt) {
|
||
result.AccidentDate = this.convertToPersianDate(input.createdAt);
|
||
result.AnnouncementDate = result.AccidentDate;
|
||
result.DocReceivedDate = result.AccidentDate;
|
||
result.AccidentTime = this.getTime24Hour(input.createdAt);
|
||
}
|
||
|
||
result.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS;
|
||
|
||
result.EstimateAmount = this.normalizeFanavaranEstimateAmount(
|
||
input.damageParts ? this.calculateEstimateAmount(input.damageParts) : 0,
|
||
);
|
||
|
||
this.applyFanavaranDefaultFields(result);
|
||
|
||
const policyId = await input.resolvePolicyId();
|
||
if (policyId === undefined || policyId === null) {
|
||
throw new BadRequestException(
|
||
`${input.logPrefix} PolicyId is required for Fanavaran submit. Policy inquiry returned no valid policy; contact the administrator.`,
|
||
);
|
||
}
|
||
result.PolicyId = policyId;
|
||
|
||
return result;
|
||
}
|
||
|
||
private formatFanavaranSelectedPartsDesc(selectedParts: unknown[]): string {
|
||
const labels = selectedParts
|
||
.map((part) => {
|
||
if (typeof part === "string") return part;
|
||
if (part && typeof part === "object") {
|
||
const p = part as {
|
||
label_fa?: unknown;
|
||
titleFa?: unknown;
|
||
name?: unknown;
|
||
catalogKey?: unknown;
|
||
};
|
||
return p.label_fa ?? p.titleFa ?? p.name ?? p.catalogKey;
|
||
}
|
||
return null;
|
||
})
|
||
.filter(
|
||
(label): label is string => typeof label === "string" && !!label.trim(),
|
||
)
|
||
.map((label) => label.trim());
|
||
|
||
return labels.length ? labels.join("/") : "موارد آسیب دیده خودرو";
|
||
}
|
||
|
||
private getFanavaranPlateMiddleCode(
|
||
centerAlphabet?: string | number | null,
|
||
): number | null {
|
||
if (centerAlphabet == null) return null;
|
||
const raw = String(centerAlphabet).trim();
|
||
if (!raw) return null;
|
||
const asNumber = Number(raw);
|
||
if (Number.isFinite(asNumber)) return asNumber;
|
||
return FANAVARAN_PLATE_LETTER_CODE[raw] ?? null;
|
||
}
|
||
|
||
private formatFanavaranPlateNo(
|
||
plate: {
|
||
leftDigits: number;
|
||
centerAlphabet: string;
|
||
centerDigits: number;
|
||
ir: number;
|
||
} | null,
|
||
): string | null {
|
||
if (!plate) return null;
|
||
return `${plate.centerDigits}${plate.centerAlphabet}${plate.leftDigits}`;
|
||
}
|
||
|
||
private buildFanavaranDamageCasePayload(input: {
|
||
claimCase: any;
|
||
blameCase?: any;
|
||
selectedParts: unknown[];
|
||
defaults: {
|
||
AccidentVehicleUsedId: number;
|
||
CulpritLicenceTypeId: number;
|
||
DmgCaseTypeId: number;
|
||
DmgHistoryStatus: number;
|
||
PlaqueKindId: number;
|
||
PlaqueSampleId: number;
|
||
DriverIsOwner: number;
|
||
FaultPercent: number;
|
||
};
|
||
}): Record<string, unknown> {
|
||
const plate = this.resolveOwnershipPlateForClaim(
|
||
input.claimCase,
|
||
input.blameCase,
|
||
);
|
||
const damagedParty = Array.isArray(input.blameCase?.parties)
|
||
? input.blameCase.parties.find(
|
||
(party: any) =>
|
||
String(party?.person?.userId ?? "") ===
|
||
String(input.claimCase?.owner?.userId ?? ""),
|
||
)
|
||
: null;
|
||
const person = damagedParty?.person ?? {};
|
||
const vehicle = damagedParty?.vehicle ?? {};
|
||
const insurance = damagedParty?.insurance ?? {};
|
||
const inquiryMapped = vehicle?.inquiry?.mapped ?? {};
|
||
const inquiryRaw = vehicle?.inquiry?.raw ?? {};
|
||
|
||
return {
|
||
BeginDate: insurance.startDate ?? null,
|
||
BuiltYear:
|
||
Number(inquiryMapped.ModelField ?? inquiryMapped.ModelCii) || null,
|
||
ChassisNo: inquiryMapped.ChassisNo ?? inquiryRaw.ChassisNo ?? null,
|
||
DmgHistoryStatus: input.defaults.DmgHistoryStatus,
|
||
Desc: this.formatFanavaranSelectedPartsDesc(input.selectedParts),
|
||
DmgCaseTypeId: input.defaults.DmgCaseTypeId,
|
||
DriverId: null,
|
||
EndDate: insurance.endDate ?? null,
|
||
EstimateAmount: FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT,
|
||
FaultPercent: input.defaults.FaultPercent,
|
||
InsuranceCorpId: null,
|
||
DriverIsOwner: person.driverIsInsurer ? 1 : input.defaults.DriverIsOwner,
|
||
LicenceCityId: null,
|
||
LicenceCountryId: null,
|
||
LicenceForeignCityName: null,
|
||
LicenceIssuDate: person.driverBirthday ?? "1394/10/13",
|
||
LicenceNo: person.driverLicense ?? "1124242",
|
||
LicenceTypeId: input.defaults.CulpritLicenceTypeId,
|
||
MotorNo: inquiryMapped.MotorNo ?? inquiryRaw.MotorNo ?? null,
|
||
OwnerId: null,
|
||
PlaqueCityId: null,
|
||
PlaqueKindId: plate ? input.defaults.PlaqueKindId : null,
|
||
PlaqueLeftNo: plate ? String(plate.leftDigits) : null,
|
||
PlaqueMiddleCodeId: this.getFanavaranPlateMiddleCode(
|
||
plate?.centerAlphabet,
|
||
),
|
||
PlaqueNo: this.formatFanavaranPlateNo(plate),
|
||
PlaqueRightNo: plate ? String(plate.centerDigits) : null,
|
||
PlaqueSampleId: plate ? input.defaults.PlaqueSampleId : null,
|
||
PlaqueSerial: plate ? String(plate.ir) : null,
|
||
PolicyNo: insurance.policyNumber ?? null,
|
||
PreviousPolicyEndDate: insurance.endDate ?? "",
|
||
VehicleKindId: null,
|
||
VIN: inquiryMapped.VIN ?? inquiryRaw.VIN ?? null,
|
||
AccidentVehicleUsedId: input.defaults.AccidentVehicleUsedId,
|
||
PolicyCINumber: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format
|
||
*/
|
||
async fanavaranSubmit(claimRequestId: string): Promise<any> {
|
||
try {
|
||
// Query claim-request-management with only needed fields using aggregate for field selection
|
||
const claimRequestResult =
|
||
await this.claimRequestManagementDbService.aggregate([
|
||
{ $match: { _id: new Types.ObjectId(claimRequestId) } },
|
||
{
|
||
$project: {
|
||
blameFile: 1,
|
||
damageExpertReply: 1,
|
||
damageExpertReplyFinal: 1,
|
||
},
|
||
},
|
||
]);
|
||
|
||
if (!claimRequestResult || claimRequestResult.length === 0) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
const claimRequest = claimRequestResult[0];
|
||
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
if (!claimRequest.blameFile || !claimRequest.blameFile._id) {
|
||
throw new BadRequestException("Blame file not found in claim request");
|
||
}
|
||
|
||
const blameRequestId = claimRequest.blameFile._id.toString();
|
||
|
||
// Query request-management with only needed fields using aggregate for field selection
|
||
const blameRequestResult = await this.blameDbService.aggregate([
|
||
{ $match: { _id: new Types.ObjectId(blameRequestId) } },
|
||
{
|
||
$project: {
|
||
expertSubmitReplyFinal: 1,
|
||
expertSubmitReply: 1,
|
||
createdAt: 1,
|
||
firstPartyLocation: 1,
|
||
"firstPartyDetails.firstPartyId": 1,
|
||
"firstPartyDetails.nationalCodeOfInsurer": 1,
|
||
"secondPartyDetails.secondPartyId": 1,
|
||
"secondPartyDetails.nationalCodeOfInsurer": 1,
|
||
},
|
||
},
|
||
]);
|
||
|
||
if (!blameRequestResult || blameRequestResult.length === 0) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
|
||
const blameRequest = blameRequestResult[0];
|
||
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
|
||
// Start building the response object
|
||
const result: any = {
|
||
AccidentCityId: 701,
|
||
AccidentReportTypeId: 155,
|
||
AccidentVehicleUsedId: 1,
|
||
ClaimExpertId: 1589,
|
||
CompensationReferenceId: 167,
|
||
CulpritLicenceTypeId: 2,
|
||
CulpritTypeId: 337,
|
||
AccidentCauseId: FANAVARAN_DEFAULT_ACCIDENT_CAUSE_ID,
|
||
};
|
||
|
||
if (
|
||
!blameRequest.expertSubmitReplyFinal &&
|
||
!blameRequest.expertSubmitReply
|
||
) {
|
||
this.logger.error(
|
||
`[Fanavaran Submit] Both expertSubmitReplyFinal and expertSubmitReply are null/undefined`,
|
||
);
|
||
}
|
||
|
||
// AccidentDate: Convert createdAt to Persian date
|
||
if (blameRequest.createdAt) {
|
||
result.AccidentDate = this.convertToPersianDate(blameRequest.createdAt);
|
||
result.AnnouncementDate = result.AccidentDate;
|
||
result.DocReceivedDate = result.AccidentDate;
|
||
}
|
||
|
||
// AccidentTime: Extract time from createdAt in 24-hour format
|
||
if (blameRequest.createdAt) {
|
||
result.AccidentTime = this.getTime24Hour(blameRequest.createdAt);
|
||
}
|
||
|
||
result.AccidentLocationAddress = FANAVARAN_ACCIDENT_LOCATION_ADDRESS;
|
||
|
||
// EstimateAmount: Sum of totalPayment from damageExpertReply parts
|
||
const damageReply =
|
||
claimRequest.damageExpertReplyFinal || claimRequest.damageExpertReply;
|
||
if (damageReply?.parts) {
|
||
result.EstimateAmount = this.normalizeFanavaranEstimateAmount(
|
||
this.calculateEstimateAmount(damageReply.parts),
|
||
);
|
||
} else {
|
||
result.EstimateAmount = FANAVARAN_PROVISIONAL_ESTIMATE_AMOUNT;
|
||
}
|
||
|
||
// Keep other fields from template with default values
|
||
this.applyFanavaranDefaultFields(result);
|
||
|
||
// Get PolicyId from external API
|
||
try {
|
||
const policyId = await this.getPolicyIdFromExternalApi(
|
||
blameRequest,
|
||
claimRequestId,
|
||
);
|
||
if (!policyId) {
|
||
throw new BadRequestException(
|
||
`[Fanavaran Submit] PolicyId is required for Fanavaran submit. Policy inquiry returned no valid policy; contact the administrator.`,
|
||
);
|
||
}
|
||
result.PolicyId = policyId;
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[Fanavaran Submit] Failed to get PolicyId from external API:`,
|
||
error,
|
||
);
|
||
throw error;
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
this.logger.error("Error in fanavaranSubmit", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 1: Get appToken from GetAppToken API
|
||
*/
|
||
private async getAppToken(
|
||
config: {
|
||
appName: string;
|
||
secret: string;
|
||
} = this.getTejaratnoFanavaranConfig(),
|
||
auditSession?: FanavaranAuditSession,
|
||
): Promise<string> {
|
||
const startedAt = Date.now();
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||
status: FanavaranAuditStatus.STARTED,
|
||
requestUrl: this.GET_APP_TOKEN_URL,
|
||
requestMeta: { appName: config.appName },
|
||
});
|
||
}
|
||
|
||
try {
|
||
const requestHeaders: any = {
|
||
appname: config.appName,
|
||
secret: config.secret,
|
||
"Content-Length": "0",
|
||
};
|
||
delete requestHeaders["Content-Type"];
|
||
|
||
const response = await firstValueFrom(
|
||
this.httpService.post(this.GET_APP_TOKEN_URL, "", {
|
||
headers: requestHeaders,
|
||
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) {
|
||
this.logger.error("Failed to get appToken from response headers");
|
||
throw new BadGatewayException(
|
||
"Failed to get appToken from response headers",
|
||
);
|
||
}
|
||
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||
status: FanavaranAuditStatus.SUCCESS,
|
||
requestUrl: this.GET_APP_TOKEN_URL,
|
||
httpStatus: response.status,
|
||
responseMeta: { hasAppToken: true },
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
}
|
||
|
||
this.logger.log(`Successfully obtained appToken`);
|
||
return appToken;
|
||
} catch (error) {
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.GET_APP_TOKEN,
|
||
status: FanavaranAuditStatus.FAILURE,
|
||
requestUrl: this.GET_APP_TOKEN_URL,
|
||
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
|
||
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
||
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
}
|
||
|
||
this.logger.error("Failed to get appToken", error);
|
||
if (isAxiosError(error)) {
|
||
const errorMessage =
|
||
error.response?.data?.Message ||
|
||
error.response?.data?.message ||
|
||
error.response?.data ||
|
||
error.message ||
|
||
"Failed to get appToken from external API";
|
||
throw new BadGatewayException(
|
||
this.fanavaranAuditService.formatErrorWithTrackingCode(
|
||
String(errorMessage),
|
||
auditSession?.trackingCode,
|
||
),
|
||
);
|
||
}
|
||
throw new BadGatewayException(
|
||
this.fanavaranAuditService.formatErrorWithTrackingCode(
|
||
"Failed to get appToken from external API",
|
||
auditSession?.trackingCode,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 2: Login to get authenticationToken
|
||
*/
|
||
private async login(
|
||
appToken: string,
|
||
config: {
|
||
username: string;
|
||
password: string;
|
||
} = this.getTejaratnoFanavaranConfig(),
|
||
auditSession?: FanavaranAuditSession,
|
||
): Promise<string> {
|
||
const startedAt = Date.now();
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.LOGIN,
|
||
status: FanavaranAuditStatus.STARTED,
|
||
requestUrl: this.LOGIN_URL,
|
||
requestMeta: { userName: config.username },
|
||
});
|
||
}
|
||
|
||
try {
|
||
const requestHeaders: any = {
|
||
appToken: appToken,
|
||
userName: config.username,
|
||
password: config.password,
|
||
"Content-Length": "0",
|
||
};
|
||
delete requestHeaders["Content-Type"];
|
||
|
||
const response = await firstValueFrom(
|
||
this.httpService.post(this.LOGIN_URL, "", {
|
||
headers: requestHeaders,
|
||
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) {
|
||
this.logger.error("Failed to get authenticationToken from response");
|
||
throw new BadGatewayException(
|
||
"Failed to get authenticationToken from response",
|
||
);
|
||
}
|
||
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.LOGIN,
|
||
status: FanavaranAuditStatus.SUCCESS,
|
||
requestUrl: this.LOGIN_URL,
|
||
httpStatus: response.status,
|
||
responseMeta: { hasAuthenticationToken: true },
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
}
|
||
|
||
this.logger.log(`Successfully obtained authenticationToken`);
|
||
return authenticationToken;
|
||
} catch (error) {
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.LOGIN,
|
||
status: FanavaranAuditStatus.FAILURE,
|
||
requestUrl: this.LOGIN_URL,
|
||
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
|
||
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
||
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
}
|
||
|
||
this.logger.error("Failed to login", error);
|
||
if (isAxiosError(error)) {
|
||
const errorMessage =
|
||
error.response?.data?.Message ||
|
||
error.response?.data?.message ||
|
||
error.response?.data ||
|
||
error.message ||
|
||
"Failed to login to external API";
|
||
throw new BadGatewayException(
|
||
this.fanavaranAuditService.formatErrorWithTrackingCode(
|
||
String(errorMessage),
|
||
auditSession?.trackingCode,
|
||
),
|
||
);
|
||
}
|
||
throw new BadGatewayException(
|
||
this.fanavaranAuditService.formatErrorWithTrackingCode(
|
||
"Failed to login to external API",
|
||
auditSession?.trackingCode,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
private async getPolicyIdFromNationalCode(
|
||
nationalCodeOfInsurer: string,
|
||
config: {
|
||
appName: string;
|
||
secret: string;
|
||
username: string;
|
||
password: string;
|
||
corpId: string;
|
||
contractId: string;
|
||
location: string;
|
||
},
|
||
logPrefix: string,
|
||
auditSession?: FanavaranAuditSession,
|
||
): Promise<number | null> {
|
||
const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
|
||
const startedAt = Date.now();
|
||
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.POLICY_INQUIRY,
|
||
status: FanavaranAuditStatus.STARTED,
|
||
requestUrl: policyInquiryUrl,
|
||
requestMeta: {
|
||
corpId: config.corpId,
|
||
contractId: config.contractId,
|
||
location: config.location,
|
||
nationalCode: this.fanavaranAuditService.maskNationalCode(
|
||
nationalCodeOfInsurer,
|
||
),
|
||
},
|
||
});
|
||
}
|
||
|
||
try {
|
||
const appToken = await this.getAppToken(config, auditSession);
|
||
const authenticationToken = await this.login(
|
||
appToken,
|
||
config,
|
||
auditSession,
|
||
);
|
||
|
||
this.logger.log(
|
||
`${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`,
|
||
);
|
||
|
||
const response = await firstValueFrom(
|
||
this.httpService.get(policyInquiryUrl, {
|
||
headers: {
|
||
authenticationToken: authenticationToken,
|
||
CorpId: config.corpId,
|
||
ContractId: config.contractId,
|
||
Location: config.location,
|
||
"Content-Type": "application/json",
|
||
},
|
||
timeout: 15000,
|
||
}),
|
||
);
|
||
|
||
const policyCount = Array.isArray(response.data)
|
||
? response.data.length
|
||
: 0;
|
||
|
||
this.logger.log(
|
||
`${logPrefix} Policy inquiry response status=${response.status} count=${policyCount}`,
|
||
);
|
||
this.logger.log(
|
||
`${logPrefix} Policy inquiry raw response: ${JSON.stringify(
|
||
response.data,
|
||
null,
|
||
2,
|
||
)}`,
|
||
);
|
||
const selectedPolicy = selectLatestActiveFanavaranPolicy(response.data);
|
||
this.logger.log(
|
||
`${logPrefix} Selected latest active policy PolicyId=${selectedPolicy.policyId} EndDate=${selectedPolicy.endDate}`,
|
||
);
|
||
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.POLICY_INQUIRY,
|
||
status: FanavaranAuditStatus.SUCCESS,
|
||
requestUrl: policyInquiryUrl,
|
||
httpStatus: response.status,
|
||
responseMeta: {
|
||
policyId: selectedPolicy.policyId,
|
||
policyEndDate: selectedPolicy.endDate,
|
||
policyEndDateGregorian: selectedPolicy.endDateGregorian,
|
||
policyCount,
|
||
},
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
}
|
||
return selectedPolicy.policyId;
|
||
} catch (error) {
|
||
const errorMessage =
|
||
error instanceof Error ? error.message : "unknown policy inquiry error";
|
||
if (auditSession) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.POLICY_INQUIRY,
|
||
status: FanavaranAuditStatus.FAILURE,
|
||
requestUrl: policyInquiryUrl,
|
||
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
|
||
errorMessage,
|
||
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
}
|
||
this.logger.warn(`${logPrefix} Policy inquiry failed: ${errorMessage}`);
|
||
if (error instanceof BadRequestException) {
|
||
throw error;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get PolicyId from external API
|
||
*/
|
||
private async getPolicyIdFromExternalApi(
|
||
blameRequest: any,
|
||
claimRequestId: string,
|
||
): Promise<number | null> {
|
||
try {
|
||
// Get guiltyUserId from expertSubmitReplyFinal or expertSubmitReply
|
||
let guiltyUserId: string | null = null;
|
||
if (blameRequest.expertSubmitReplyFinal?.guiltyUserId) {
|
||
guiltyUserId = blameRequest.expertSubmitReplyFinal.guiltyUserId;
|
||
} else if (blameRequest.expertSubmitReply?.guiltyUserId) {
|
||
guiltyUserId = blameRequest.expertSubmitReply.guiltyUserId;
|
||
}
|
||
|
||
if (!guiltyUserId) {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] guiltyUserId not found in expertSubmitReplyFinal or expertSubmitReply`,
|
||
);
|
||
return null;
|
||
}
|
||
|
||
// Find the nationalCodeOfInsurer by matching guiltyUserId with firstPartyId or secondPartyId
|
||
let nationalCodeOfInsurer: string | null = null;
|
||
|
||
if (
|
||
blameRequest.firstPartyDetails?.firstPartyId?.toString() ===
|
||
guiltyUserId.toString()
|
||
) {
|
||
nationalCodeOfInsurer =
|
||
blameRequest.firstPartyDetails?.nationalCodeOfInsurer;
|
||
} else if (
|
||
blameRequest.secondPartyDetails?.secondPartyId?.toString() ===
|
||
guiltyUserId.toString()
|
||
) {
|
||
nationalCodeOfInsurer =
|
||
blameRequest.secondPartyDetails?.nationalCodeOfInsurer;
|
||
}
|
||
|
||
if (!nationalCodeOfInsurer) {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] nationalCodeOfInsurer not found for guiltyUserId: ${guiltyUserId}`,
|
||
);
|
||
return null;
|
||
}
|
||
|
||
return await this.getPolicyIdFromNationalCode(
|
||
nationalCodeOfInsurer,
|
||
this.getTejaratnoFanavaranConfig(),
|
||
`[Fanavaran Submit] claimRequestId=${claimRequestId}`,
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[Fanavaran Submit] Error getting PolicyId from external API:`,
|
||
error,
|
||
);
|
||
if (error instanceof BadRequestException) {
|
||
throw error;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private getNationalCodeOfInsurerForGuiltyPartyV2(
|
||
parties: Array<{
|
||
role?: PartyRole;
|
||
person?: { userId?: Types.ObjectId; nationalCodeOfInsurer?: string };
|
||
statement?: { admitsGuilt?: boolean };
|
||
}>,
|
||
guiltyPartyId?: Types.ObjectId | string | null,
|
||
): string | null {
|
||
if (!guiltyPartyId) {
|
||
return null;
|
||
}
|
||
|
||
const guiltyParty = parties.find(
|
||
(party) => party.person?.userId?.toString() === guiltyPartyId.toString(),
|
||
);
|
||
|
||
return guiltyParty?.person?.nationalCodeOfInsurer ?? null;
|
||
}
|
||
|
||
private resolveGuiltyPartyIdV2(
|
||
parties: Array<{
|
||
role?: PartyRole;
|
||
person?: { userId?: Types.ObjectId };
|
||
statement?: { admitsGuilt?: boolean };
|
||
}>,
|
||
decisionGuiltyPartyId?: Types.ObjectId | string | null,
|
||
): Types.ObjectId | string | null {
|
||
if (decisionGuiltyPartyId) {
|
||
return decisionGuiltyPartyId;
|
||
}
|
||
|
||
const admitsGuiltParty = parties.find(
|
||
(party) => party.statement?.admitsGuilt && party.person?.userId,
|
||
);
|
||
if (admitsGuiltParty?.person?.userId) {
|
||
return admitsGuiltParty.person.userId;
|
||
}
|
||
|
||
// Legacy-compatible safety fallback: when no explicit guilty marker exists,
|
||
// use FIRST party if available (same heuristic used by older flows).
|
||
const firstParty = parties.find(
|
||
(party) => party.role === PartyRole.FIRST && party.person?.userId,
|
||
);
|
||
return firstParty?.person?.userId ?? null;
|
||
}
|
||
|
||
/**
|
||
* Preview Fanavaran submit body (V2 claimCases + blameCases) for a specific client.
|
||
*/
|
||
async previewFanavaranSubmitV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
options?: {
|
||
debug?: boolean;
|
||
auditSession?: FanavaranAuditSession;
|
||
},
|
||
): Promise<any> {
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`;
|
||
const auditSession =
|
||
options?.auditSession ??
|
||
({
|
||
trackingCode: this.fanavaranAuditService.generateTrackingCode(),
|
||
clientKey,
|
||
source: FanavaranAuditSource.PREVIEW,
|
||
claimCaseId,
|
||
} satisfies FanavaranAuditSession);
|
||
const buildStartedAt = Date.now();
|
||
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.BUILD_PAYLOAD,
|
||
status: FanavaranAuditStatus.STARTED,
|
||
});
|
||
|
||
try {
|
||
const debug = {
|
||
clientKey,
|
||
claimCaseId,
|
||
steps: {
|
||
claimCaseFound: false,
|
||
blameRequestLinked: false,
|
||
blameCaseFound: false,
|
||
expertDecisionFound: false,
|
||
accidentReasonFound: false,
|
||
firstPartyFound: false,
|
||
firstPartyLocationFound: false,
|
||
damageReplyFound: false,
|
||
guiltyPartyIdFound: false,
|
||
nationalCodeOfInsurerFound: false,
|
||
policyInquiryAttempted: false,
|
||
policyInquirySucceeded: false,
|
||
accidentReasonFallbackFromSnapshot: false,
|
||
guiltyPartyFallbackUsed: false,
|
||
},
|
||
values: {
|
||
blameRequestId: null as string | null,
|
||
guiltyPartyId: null as string | null,
|
||
nationalCodeOfInsurer: null as string | null,
|
||
policyId: null as number | null,
|
||
estimateAmount: null as number | null,
|
||
},
|
||
failureReason: null as string | null,
|
||
};
|
||
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException("Claim case not found");
|
||
}
|
||
debug.steps.claimCaseFound = true;
|
||
|
||
if (!claimCase.blameRequestId) {
|
||
debug.failureReason = "claimCase.blameRequestId is missing";
|
||
throw new BadRequestException("Blame case not linked to claim case");
|
||
}
|
||
debug.steps.blameRequestLinked = true;
|
||
debug.values.blameRequestId = claimCase.blameRequestId.toString();
|
||
|
||
const blameCase = await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId,
|
||
);
|
||
if (!blameCase) {
|
||
throw new NotFoundException("Blame case not found");
|
||
}
|
||
debug.steps.blameCaseFound = true;
|
||
|
||
const expertDecision = blameCase.expert?.decision;
|
||
debug.steps.expertDecisionFound = !!expertDecision;
|
||
const fallbackAccidentReason =
|
||
claimCase.snapshot?.accident?.classification?.accidentReason;
|
||
if (!expertDecision?.fields?.accidentReason && fallbackAccidentReason) {
|
||
debug.steps.accidentReasonFallbackFromSnapshot = true;
|
||
}
|
||
const selectedAccidentReason =
|
||
expertDecision?.fields?.accidentReason ?? fallbackAccidentReason;
|
||
debug.steps.accidentReasonFound = !!selectedAccidentReason;
|
||
|
||
const firstParty = blameCase.parties?.find(
|
||
(party) => party.role === PartyRole.FIRST,
|
||
);
|
||
debug.steps.firstPartyFound = !!firstParty;
|
||
debug.steps.firstPartyLocationFound = !!firstParty?.location;
|
||
const activeExpertReply = getActiveV2ExpertReply(
|
||
claimCase as unknown as { evaluation?: Record<string, unknown> },
|
||
);
|
||
const damageParts = activeExpertReply?.parts;
|
||
debug.steps.damageReplyFound = !!damageParts?.length;
|
||
if (options?.debug) {
|
||
debug.values.estimateAmount = damageParts
|
||
? this.calculateEstimateAmount(damageParts)
|
||
: 0;
|
||
}
|
||
|
||
const payload = await this.buildFanavaranSubmitPayload({
|
||
accidentReason: selectedAccidentReason,
|
||
createdAt: (blameCase as { createdAt?: Date }).createdAt,
|
||
location: firstParty?.location,
|
||
damageParts,
|
||
defaults: profile.defaults,
|
||
logPrefix,
|
||
resolvePolicyId: async () => {
|
||
const guiltyPartyId = this.resolveGuiltyPartyIdV2(
|
||
blameCase.parties ?? [],
|
||
expertDecision?.guiltyPartyId,
|
||
);
|
||
if (!expertDecision?.guiltyPartyId && guiltyPartyId) {
|
||
debug.steps.guiltyPartyFallbackUsed = true;
|
||
}
|
||
debug.steps.guiltyPartyIdFound = !!guiltyPartyId;
|
||
debug.values.guiltyPartyId = guiltyPartyId
|
||
? guiltyPartyId.toString()
|
||
: null;
|
||
const nationalCodeOfInsurer =
|
||
this.getNationalCodeOfInsurerForGuiltyPartyV2(
|
||
blameCase.parties ?? [],
|
||
guiltyPartyId,
|
||
);
|
||
debug.steps.nationalCodeOfInsurerFound = !!nationalCodeOfInsurer;
|
||
debug.values.nationalCodeOfInsurer = nationalCodeOfInsurer;
|
||
|
||
if (!guiltyPartyId) {
|
||
this.logger.warn(
|
||
`${logPrefix} guiltyPartyId not found in expert.decision`,
|
||
);
|
||
debug.failureReason = "expert.decision.guiltyPartyId is missing";
|
||
return null;
|
||
}
|
||
|
||
if (!nationalCodeOfInsurer) {
|
||
this.logger.warn(
|
||
`${logPrefix} nationalCodeOfInsurer not found for guiltyPartyId: ${guiltyPartyId}`,
|
||
);
|
||
debug.failureReason =
|
||
"nationalCodeOfInsurer not found for guilty party in blameCase.parties";
|
||
return null;
|
||
}
|
||
|
||
debug.steps.policyInquiryAttempted = true;
|
||
const policyId = await this.getPolicyIdFromNationalCode(
|
||
nationalCodeOfInsurer,
|
||
profile.auth,
|
||
logPrefix,
|
||
auditSession,
|
||
);
|
||
debug.values.policyId = policyId;
|
||
debug.steps.policyInquirySucceeded = policyId !== null;
|
||
if (policyId === null) {
|
||
debug.failureReason =
|
||
debug.failureReason ??
|
||
"policy inquiry returned no PolicyId (timeout, network error, or empty response)";
|
||
}
|
||
return policyId;
|
||
},
|
||
});
|
||
|
||
if (options?.debug) {
|
||
return {
|
||
clientKey,
|
||
payload,
|
||
debug,
|
||
};
|
||
}
|
||
|
||
return payload;
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in previewFanavaranSubmitV2 (${clientKey})`,
|
||
error,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/** @deprecated Use previewFanavaranSubmitV2(claimCaseId, "parsian", options) */
|
||
async fanavaranSubmitParsianV2(
|
||
claimCaseId: string,
|
||
options?: { debug?: boolean },
|
||
): Promise<any> {
|
||
return this.previewFanavaranSubmitV2(claimCaseId, "parsian", options);
|
||
}
|
||
|
||
/**
|
||
* Submit Fanavaran data (V2 claimCases + blameCases) for a specific client.
|
||
*/
|
||
async submitFanavaranV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
try {
|
||
return await this.executeFanavaranV2Submit(claimCaseId, clientKey);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[Fanavaran ${clientKey} V2] Error submitting to Fanavaran`,
|
||
error,
|
||
);
|
||
throw new BadGatewayException(this.extractFanavaranErrorMessage(error));
|
||
}
|
||
}
|
||
|
||
/** @deprecated Use submitFanavaranV2(claimCaseId, "parsian") */
|
||
async submitToFanavaranParsianV2(claimCaseId: string): Promise<any> {
|
||
return this.submitFanavaranV2(claimCaseId, "parsian");
|
||
}
|
||
|
||
async previewFanavaranDamageCaseV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException("Claim case not found");
|
||
}
|
||
if (!claimCase.blameRequestId) {
|
||
throw new BadRequestException("Blame case not linked to claim case");
|
||
}
|
||
const blameCase = await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId,
|
||
);
|
||
if (!blameCase) {
|
||
throw new NotFoundException("Blame case not found");
|
||
}
|
||
if (blameCase.type !== BlameRequestType.THIRD_PARTY) {
|
||
throw new BadRequestException(
|
||
"Fanavaran damage-case submit only applies to THIRD_PARTY claims",
|
||
);
|
||
}
|
||
|
||
const payload = this.buildFanavaranDamageCasePayload({
|
||
claimCase,
|
||
blameCase,
|
||
selectedParts: claimCase.damage?.selectedParts ?? [],
|
||
defaults: profile.defaults,
|
||
});
|
||
|
||
return {
|
||
clientKey,
|
||
claimCaseId,
|
||
claimId: claimCase.claimId ?? null,
|
||
dmgCaseId: claimCase.dmgCaseId ?? null,
|
||
submitUrl: claimCase.claimId
|
||
? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/dmg-cases`
|
||
: null,
|
||
warning: claimCase.claimId
|
||
? undefined
|
||
: "Fanavaran base claimId is required before submit.",
|
||
payload,
|
||
};
|
||
}
|
||
|
||
async submitFanavaranDamageCaseV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
try {
|
||
return await this.executeFanavaranDamageCaseSubmit(
|
||
claimCaseId,
|
||
clientKey,
|
||
undefined,
|
||
FanavaranAuditSource.SUBMIT,
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[Fanavaran ${clientKey} V2] Error submitting damage case to Fanavaran`,
|
||
error,
|
||
);
|
||
throw new BadGatewayException(this.extractFanavaranErrorMessage(error));
|
||
}
|
||
}
|
||
|
||
private getSubmittedFanavaranAttachmentFileNames(
|
||
claimCase: any,
|
||
): Map<string, unknown> {
|
||
const submitted = new Map<string, unknown>();
|
||
const files = claimCase?.fanavaranSync?.attachments?.files;
|
||
if (!Array.isArray(files)) return submitted;
|
||
|
||
for (const file of files) {
|
||
const fileName = (file as { fileName?: unknown })?.fileName;
|
||
if (typeof fileName === "string" && fileName.trim()) {
|
||
submitted.set(fileName, (file as { fileId?: unknown })?.fileId);
|
||
}
|
||
}
|
||
|
||
return submitted;
|
||
}
|
||
|
||
private appendFanavaranAttachmentCandidate(
|
||
candidates: FanavaranAttachmentCandidate[],
|
||
seen: Set<string>,
|
||
submittedFiles: Map<string, unknown>,
|
||
input: { path?: unknown; fileName?: unknown; source: string },
|
||
fileTypeId: number,
|
||
): void {
|
||
const path = typeof input.path === "string" ? input.path : "";
|
||
const fileName =
|
||
typeof input.fileName === "string" && input.fileName.trim()
|
||
? input.fileName
|
||
: path
|
||
? basename(path)
|
||
: "";
|
||
if (!path || !fileName || seen.has(fileName)) return;
|
||
|
||
seen.add(fileName);
|
||
candidates.push({
|
||
path,
|
||
fileName,
|
||
source: input.source,
|
||
content: {
|
||
FileName: fileName,
|
||
FileTypeId: fileTypeId,
|
||
Files: [{ FileName: fileName, FileTypeId: fileTypeId }],
|
||
},
|
||
alreadySubmitted: submittedFiles.has(fileName),
|
||
submittedFileId: submittedFiles.get(fileName),
|
||
});
|
||
}
|
||
|
||
private async collectFanavaranAttachmentCandidates(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<{
|
||
claimCase: any;
|
||
candidates: FanavaranAttachmentCandidate[];
|
||
}> {
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException("Claim case not found");
|
||
}
|
||
|
||
const candidates: FanavaranAttachmentCandidate[] = [];
|
||
const seen = new Set<string>();
|
||
const submittedFiles =
|
||
this.getSubmittedFanavaranAttachmentFileNames(claimCase);
|
||
const fileTypeId = profile.defaults.ClaimFileTypeId;
|
||
|
||
const documents =
|
||
await this.claimRequiredDocumentDbService.findByClaimId(claimCaseId);
|
||
for (const document of documents) {
|
||
this.appendFanavaranAttachmentCandidate(
|
||
candidates,
|
||
seen,
|
||
submittedFiles,
|
||
{
|
||
path: (document as { path?: unknown }).path,
|
||
fileName: (document as { fileName?: unknown }).fileName,
|
||
source: `document:${String((document as { documentType?: unknown }).documentType ?? "unknown")}`,
|
||
},
|
||
fileTypeId,
|
||
);
|
||
}
|
||
|
||
const carAngles = claimCase.media?.carAngles;
|
||
const angleEntries =
|
||
carAngles instanceof Map
|
||
? Array.from(carAngles.entries())
|
||
: carAngles && typeof carAngles === "object"
|
||
? Object.entries(carAngles)
|
||
: [];
|
||
for (const [angleKey, image] of angleEntries) {
|
||
const capture = image as { path?: unknown; fileName?: unknown };
|
||
this.appendFanavaranAttachmentCandidate(
|
||
candidates,
|
||
seen,
|
||
submittedFiles,
|
||
{
|
||
path: capture?.path,
|
||
fileName: capture?.fileName,
|
||
source: `capture:angle:${angleKey}`,
|
||
},
|
||
fileTypeId,
|
||
);
|
||
}
|
||
|
||
const damagedParts = claimCase.media?.damagedParts;
|
||
const partEntries = Array.isArray(damagedParts)
|
||
? damagedParts.map((part, index) => [String(index), part] as const)
|
||
: damagedParts && typeof damagedParts === "object"
|
||
? Object.entries(damagedParts)
|
||
: [];
|
||
for (const [partKey, image] of partEntries) {
|
||
const capture = image as { path?: unknown; fileName?: unknown };
|
||
this.appendFanavaranAttachmentCandidate(
|
||
candidates,
|
||
seen,
|
||
submittedFiles,
|
||
{
|
||
path: capture?.path,
|
||
fileName: capture?.fileName,
|
||
source: `capture:part:${partKey}`,
|
||
},
|
||
fileTypeId,
|
||
);
|
||
}
|
||
|
||
return { claimCase, candidates };
|
||
}
|
||
|
||
async previewFanavaranAttachmentsV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
const { claimCase, candidates } =
|
||
await this.collectFanavaranAttachmentCandidates(claimCaseId, clientKey);
|
||
|
||
return {
|
||
clientKey,
|
||
claimCaseId,
|
||
claimId: claimCase.claimId ?? null,
|
||
submitUrl: claimCase.claimId
|
||
? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`
|
||
: null,
|
||
warning: claimCase.claimId
|
||
? undefined
|
||
: "Fanavaran base claimId is required before attachment submit. Submit will retry base claim first.",
|
||
totalLocalImages: candidates.length,
|
||
alreadySubmitted: candidates.filter(
|
||
(candidate) => candidate.alreadySubmitted,
|
||
).length,
|
||
pendingSubmit: candidates.filter(
|
||
(candidate) => !candidate.alreadySubmitted,
|
||
).length,
|
||
attachments: candidates,
|
||
};
|
||
}
|
||
|
||
async submitFanavaranAttachmentsV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
const { candidates } = await this.collectFanavaranAttachmentCandidates(
|
||
claimCaseId,
|
||
clientKey,
|
||
);
|
||
const pending = candidates.filter(
|
||
(candidate) => !candidate.alreadySubmitted,
|
||
);
|
||
const results: FanavaranAttachmentSubmitResult[] = [];
|
||
|
||
for (const candidate of pending) {
|
||
results.push(
|
||
await this.autoSubmitFanavaranAttachment(
|
||
claimCaseId,
|
||
{
|
||
path: candidate.path,
|
||
fileName: candidate.fileName,
|
||
source: candidate.source,
|
||
},
|
||
{
|
||
clientKey,
|
||
auditSource: FanavaranAuditSource.SUBMIT,
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
return {
|
||
clientKey,
|
||
claimCaseId,
|
||
totalLocalImages: candidates.length,
|
||
skippedAlreadySubmitted: candidates.length - pending.length,
|
||
attempted: results.length,
|
||
submitted: results.filter((result) => result.submitted).length,
|
||
failed: results.filter(
|
||
(result) => result.attempted && !result.submitted && !result.skipped,
|
||
).length,
|
||
skipped: results.filter((result) => result.skipped).length,
|
||
results,
|
||
};
|
||
}
|
||
|
||
private fanavaranLookupDefinition(name: string) {
|
||
const definition = FANAVARAN_REMOTE_LOOKUPS.find(
|
||
(item) => item.name === name,
|
||
);
|
||
if (!definition) {
|
||
throw new Error(`Unknown Fanavaran lookup definition: ${name}`);
|
||
}
|
||
return definition;
|
||
}
|
||
|
||
private async getFanavaranLookupRows(
|
||
clientKey: FanavaranClientKey,
|
||
name: string,
|
||
): Promise<unknown[]> {
|
||
const definition = this.fanavaranLookupDefinition(name);
|
||
const data = await this.fanavaranLookupService.getRemoteLookup(
|
||
clientKey,
|
||
definition.url,
|
||
definition.cacheFile,
|
||
);
|
||
return Array.isArray(data) ? data : [];
|
||
}
|
||
|
||
private normalizeFanavaranLookupText(value: unknown): string {
|
||
return String(value ?? "")
|
||
.toLowerCase()
|
||
.replace(/[ي]/g, "ی")
|
||
.replace(/[ك]/g, "ک")
|
||
.replace(/[\u200c\s_\-\/]+/g, "")
|
||
.replace(/[^\p{L}\p{N}]/gu, "");
|
||
}
|
||
|
||
private lookupRowId(row: unknown): number | null {
|
||
if (!row || typeof row !== "object") return null;
|
||
const raw =
|
||
(row as { Id?: unknown; id?: unknown; Code?: unknown }).Id ??
|
||
(row as { id?: unknown }).id ??
|
||
(row as { Code?: unknown }).Code;
|
||
const id = Number(raw);
|
||
return Number.isFinite(id) ? id : null;
|
||
}
|
||
|
||
private lookupRowTextCandidates(row: unknown): string[] {
|
||
if (!row || typeof row !== "object") return [];
|
||
const r = row as Record<string, unknown>;
|
||
return [
|
||
r.Caption,
|
||
r.caption,
|
||
r.Title,
|
||
r.title,
|
||
r.Name,
|
||
r.name,
|
||
r.Text,
|
||
r.text,
|
||
r.Description,
|
||
r.description,
|
||
r.Value,
|
||
r.value,
|
||
]
|
||
.filter((value): value is string => typeof value === "string")
|
||
.filter(Boolean);
|
||
}
|
||
|
||
private findLookupIdByText(rows: unknown[], texts: unknown[]): number | null {
|
||
const wanted = texts
|
||
.map((text) => this.normalizeFanavaranLookupText(text))
|
||
.filter(Boolean);
|
||
if (!wanted.length) return null;
|
||
|
||
for (const row of rows) {
|
||
const rowTexts = this.lookupRowTextCandidates(row).map((text) =>
|
||
this.normalizeFanavaranLookupText(text),
|
||
);
|
||
if (
|
||
rowTexts.some((rowText) =>
|
||
wanted.some(
|
||
(want) =>
|
||
rowText === want ||
|
||
rowText.includes(want) ||
|
||
want.includes(rowText),
|
||
),
|
||
)
|
||
) {
|
||
return this.lookupRowId(row);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private parseFanavaranMoney(value: unknown): number {
|
||
if (value == null || value === "") return 0;
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
const normalized = String(value)
|
||
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
|
||
.replace(/[٠-٩]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1632))
|
||
.replace(/,/g, "")
|
||
.trim();
|
||
const parsed = Number(normalized);
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
|
||
private fanavaranPartLabel(part: any): string {
|
||
const damage = part?.carPartDamage;
|
||
if (damage && typeof damage === "object") {
|
||
const d = damage as {
|
||
label_fa?: unknown;
|
||
part?: unknown;
|
||
name?: unknown;
|
||
catalogKey?: unknown;
|
||
};
|
||
return String(
|
||
d.label_fa ?? d.part ?? d.name ?? d.catalogKey ?? part?.partId ?? "",
|
||
);
|
||
}
|
||
return String(damage ?? part?.partId ?? "");
|
||
}
|
||
|
||
private fanavaranPartLookupTexts(part: any): unknown[] {
|
||
const damage = part?.carPartDamage;
|
||
if (damage && typeof damage === "object") {
|
||
const d = damage as Record<string, unknown>;
|
||
return [
|
||
d.label_fa,
|
||
d.part,
|
||
d.name,
|
||
d.catalogKey,
|
||
part.partId,
|
||
this.fanavaranPartLabel(part),
|
||
];
|
||
}
|
||
return [damage, part?.partId, this.fanavaranPartLabel(part)];
|
||
}
|
||
|
||
private fanavaranDaghiWasteValue(daghi: unknown): number {
|
||
if (!daghi || typeof daghi !== "object")
|
||
return this.parseFanavaranMoney(daghi);
|
||
const d = daghi as { price?: unknown };
|
||
return this.parseFanavaranMoney(d.price);
|
||
}
|
||
|
||
private findPriceDropLineForPart(priceDrop: any, part: any): any {
|
||
const lines = Array.isArray(priceDrop?.partLines)
|
||
? priceDrop.partLines
|
||
: [];
|
||
return lines.find((line: any) => {
|
||
const linePartId = parseCatalogPartIdInput(line?.partId);
|
||
const partId = parseCatalogPartIdInput(part?.partId);
|
||
return linePartId != null && partId != null && linePartId === partId;
|
||
});
|
||
}
|
||
|
||
private findDropAmountStatusId(
|
||
rows: unknown[],
|
||
priceDropTotal: number,
|
||
): number | null {
|
||
const positiveTexts =
|
||
priceDropTotal > 0
|
||
? ["مشمول", "دارد", "افت", "has", "yes"]
|
||
: ["فاقد", "ندارد", "عدم", "بدون", "no"];
|
||
return this.findLookupIdByText(rows, positiveTexts);
|
||
}
|
||
|
||
private buildFanavaranExpertiseDmgSection(input: {
|
||
part: any;
|
||
priceDrop: any;
|
||
carComponents: unknown[];
|
||
accidentLevels: unknown[];
|
||
warnings: string[];
|
||
}): Record<string, unknown> {
|
||
const priceDropLine = this.findPriceDropLineForPart(
|
||
input.priceDrop,
|
||
input.part,
|
||
);
|
||
const sectionId = this.findLookupIdByText(
|
||
input.carComponents,
|
||
this.fanavaranPartLookupTexts(input.part),
|
||
);
|
||
const accidentLevel = this.findLookupIdByText(input.accidentLevels, [
|
||
priceDropLine?.severity,
|
||
input.part?.typeOfDamage,
|
||
]);
|
||
const label = this.fanavaranPartLabel(input.part);
|
||
|
||
if (sectionId == null) {
|
||
input.warnings.push(
|
||
`No Fanavaran DmgSectionId mapping for part "${label}".`,
|
||
);
|
||
}
|
||
if (accidentLevel == null) {
|
||
input.warnings.push(
|
||
`No Fanavaran AccidentLevel mapping for part "${label}".`,
|
||
);
|
||
}
|
||
|
||
return {
|
||
Desc: input.part?.typeOfDamage ?? label,
|
||
DmgSectionId: sectionId,
|
||
AccidentLevel: accidentLevel,
|
||
ComponentReplacementCost: this.parseFanavaranMoney(input.part?.price),
|
||
RepairWage: this.parseFanavaranMoney(input.part?.salary),
|
||
WasteValue: this.fanavaranDaghiWasteValue(input.part?.daghi),
|
||
};
|
||
}
|
||
|
||
private async buildFanavaranExpertisePayload(input: {
|
||
claimCase: any;
|
||
clientKey: FanavaranClientKey;
|
||
}): Promise<{
|
||
payload: Record<string, unknown>;
|
||
warnings: string[];
|
||
replyKey?: string;
|
||
}> {
|
||
const profile = getFanavaranClientProfile(input.clientKey);
|
||
const active = getActiveV2ExpertReply(input.claimCase as any);
|
||
const warnings: string[] = [];
|
||
|
||
if (!active?.parts?.length) {
|
||
warnings.push("No active damage expert reply with priced parts exists.");
|
||
}
|
||
|
||
const reply = active?.reply as any;
|
||
const parts = (active?.parts ?? []).filter((part: any) => {
|
||
if (part.factorNeeded !== true) return true;
|
||
return this.parseFanavaranMoney(part.totalPayment) > 0;
|
||
}) as any[];
|
||
|
||
if (!parts.length) {
|
||
warnings.push("No submit-ready expert pricing lines exist yet.");
|
||
}
|
||
|
||
const [
|
||
inspectionPlaces,
|
||
dropAmountStatuses,
|
||
carComponents,
|
||
accidentLevels,
|
||
] = await Promise.all([
|
||
this.getFanavaranLookupRows(input.clientKey, "inspection-place"),
|
||
this.getFanavaranLookupRows(input.clientKey, "drop-amount-status"),
|
||
this.getFanavaranLookupRows(input.clientKey, "car-components"),
|
||
this.getFanavaranLookupRows(input.clientKey, "accident-level"),
|
||
]);
|
||
|
||
const priceDrop = input.claimCase.evaluation?.priceDrop ?? {};
|
||
const priceDropTotal = this.parseFanavaranMoney(priceDrop?.total);
|
||
const inspectionPlaceId = this.findLookupIdByText(inspectionPlaces, [
|
||
input.claimCase.evaluation?.visitLocation,
|
||
"محل بازدید",
|
||
"کارشناسی",
|
||
]);
|
||
const dropAmountStatus = this.findDropAmountStatusId(
|
||
dropAmountStatuses,
|
||
priceDropTotal,
|
||
);
|
||
|
||
if (inspectionPlaceId == null) {
|
||
warnings.push(
|
||
"No Fanavaran InspectionPlaceId mapping could be resolved.",
|
||
);
|
||
}
|
||
if (dropAmountStatus == null) {
|
||
warnings.push("No Fanavaran DropAmountStatus mapping could be resolved.");
|
||
}
|
||
|
||
const dmgSections = parts.map((part) =>
|
||
this.buildFanavaranExpertiseDmgSection({
|
||
part,
|
||
priceDrop,
|
||
carComponents,
|
||
accidentLevels,
|
||
warnings,
|
||
}),
|
||
);
|
||
const repairWage = dmgSections.reduce(
|
||
(sum, section) => sum + this.parseFanavaranMoney(section.RepairWage),
|
||
0,
|
||
);
|
||
const componentReplacementCost = dmgSections.reduce(
|
||
(sum, section) =>
|
||
sum + this.parseFanavaranMoney(section.ComponentReplacementCost),
|
||
0,
|
||
);
|
||
const wasteValue = dmgSections.reduce(
|
||
(sum, section) => sum + this.parseFanavaranMoney(section.WasteValue),
|
||
0,
|
||
);
|
||
const submittedAt = reply?.submittedAt ?? new Date();
|
||
|
||
return {
|
||
replyKey: active?.replyKey,
|
||
warnings,
|
||
payload: {
|
||
ClaimExpertId:
|
||
reply?.expertProfileSnapshot?.fanavaranExpertId ??
|
||
profile.defaults.ClaimExpertId,
|
||
ComponentReplacementCost: componentReplacementCost,
|
||
DmgAssessmentDate: this.convertToPersianDate(submittedAt),
|
||
DmgCaseId: input.claimCase.dmgCaseId ?? null,
|
||
InspectionPlaceId: inspectionPlaceId,
|
||
InspectionTime: this.getTime24Hour(submittedAt),
|
||
RepairWage: repairWage,
|
||
WasteValue: wasteValue,
|
||
WentDistanceByExpert: null,
|
||
DropAmountStatus: dropAmountStatus,
|
||
DropAmountAdditionsDeductions: priceDropTotal || 0,
|
||
ComponentReplacementTaxAndToll: 0,
|
||
DamagedVehicleCurrentPrice:
|
||
this.parseFanavaranMoney(priceDrop?.carPrice) || null,
|
||
DmgSections: dmgSections,
|
||
},
|
||
};
|
||
}
|
||
|
||
private assertFanavaranExpertisePayloadReady(
|
||
payload: Record<string, unknown>,
|
||
warnings: string[],
|
||
): void {
|
||
const sections = Array.isArray(payload.DmgSections)
|
||
? payload.DmgSections
|
||
: [];
|
||
if (!payload.DmgCaseId) warnings.push("DmgCaseId is required.");
|
||
if (!payload.InspectionPlaceId)
|
||
warnings.push("InspectionPlaceId is required.");
|
||
if (!payload.DropAmountStatus)
|
||
warnings.push("DropAmountStatus is required.");
|
||
if (!sections.length)
|
||
warnings.push("At least one DmgSections row is required.");
|
||
for (const [index, section] of sections.entries()) {
|
||
const row = section as Record<string, unknown>;
|
||
if (!row.DmgSectionId) {
|
||
warnings.push(`DmgSections[${index}].DmgSectionId is required.`);
|
||
}
|
||
if (!row.AccidentLevel) {
|
||
warnings.push(`DmgSections[${index}].AccidentLevel is required.`);
|
||
}
|
||
}
|
||
if (warnings.length) {
|
||
throw new BadRequestException({
|
||
message: "Fanavaran expertise payload is not ready.",
|
||
warnings: Array.from(new Set(warnings)),
|
||
});
|
||
}
|
||
}
|
||
|
||
async previewFanavaranExpertiseV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) throw new NotFoundException("Claim case not found");
|
||
|
||
const { payload, warnings, replyKey } =
|
||
await this.buildFanavaranExpertisePayload({ claimCase, clientKey });
|
||
|
||
return {
|
||
clientKey,
|
||
claimCaseId,
|
||
claimId: claimCase.claimId ?? null,
|
||
dmgCaseId: claimCase.dmgCaseId ?? null,
|
||
expertiseId: claimCase.expertiseId ?? null,
|
||
replyKey,
|
||
submitUrl: claimCase.claimId
|
||
? `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/expertise`
|
||
: null,
|
||
ready: warnings.length === 0,
|
||
warnings: Array.from(new Set(warnings)),
|
||
payload,
|
||
};
|
||
}
|
||
|
||
async submitFanavaranExpertiseV2(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) throw new NotFoundException("Claim case not found");
|
||
const { payload, warnings } = await this.buildFanavaranExpertisePayload({
|
||
claimCase,
|
||
clientKey,
|
||
});
|
||
this.assertFanavaranExpertisePayloadReady(payload, warnings);
|
||
return await this.executeFanavaranExpertiseSubmit(
|
||
claimCaseId,
|
||
clientKey,
|
||
payload,
|
||
FanavaranAuditSource.SUBMIT,
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
`[Fanavaran ${clientKey} V2] Error submitting expertise to Fanavaran`,
|
||
error,
|
||
);
|
||
throw new BadGatewayException(this.extractFanavaranErrorMessage(error));
|
||
}
|
||
}
|
||
|
||
private async executeFanavaranExpertiseSubmit(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
payload: Record<string, unknown>,
|
||
auditSource: FanavaranAuditSource,
|
||
): Promise<any> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase?.claimId) {
|
||
throw new BadRequestException(
|
||
"Fanavaran claimId is required before submitting expertise",
|
||
);
|
||
}
|
||
const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/expertise`;
|
||
const startedAt = Date.now();
|
||
const auditSession: FanavaranAuditSession = {
|
||
trackingCode: this.fanavaranAuditService.generateTrackingCode(),
|
||
clientKey,
|
||
source: auditSource,
|
||
claimCaseId,
|
||
};
|
||
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_EXPERTISE,
|
||
status: FanavaranAuditStatus.STARTED,
|
||
requestUrl: url,
|
||
requestMeta: { claimId: claimCase.claimId, payload },
|
||
});
|
||
|
||
try {
|
||
const response = await this.postFanavaranJson(url, payload, clientKey);
|
||
const expertiseId = response.data?.Id;
|
||
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_EXPERTISE,
|
||
status: FanavaranAuditStatus.SUCCESS,
|
||
requestUrl: url,
|
||
httpStatus: response.status,
|
||
responseMeta: { claimId: claimCase.claimId, expertiseId },
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
...(expertiseId !== undefined ? { expertiseId } : {}),
|
||
"fanavaranSync.expertise": {
|
||
status: "success",
|
||
lastTriedAt: new Date(),
|
||
claimId: claimCase.claimId,
|
||
dmgCaseId: payload.DmgCaseId,
|
||
expertiseId,
|
||
response: response.data,
|
||
},
|
||
},
|
||
});
|
||
|
||
return response.data;
|
||
} catch (error) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_EXPERTISE,
|
||
status: FanavaranAuditStatus.FAILURE,
|
||
requestUrl: url,
|
||
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
|
||
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
||
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async autoSubmitFanavaranExpertiseOnExpertReply(
|
||
claimCaseId: string,
|
||
): Promise<FanavaranExpertiseSubmitResult> {
|
||
const clientKey = resolveFanavaranClientKey();
|
||
const logPrefix = `[Fanavaran ${clientKey} V2 Expertise Auto] claimCaseId=${claimCaseId}`;
|
||
try {
|
||
let claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
warning: "Claim case not found for Fanavaran expertise submit.",
|
||
};
|
||
}
|
||
if (claimCase.expertiseId != null) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason: "Expertise already submitted to Fanavaran",
|
||
claimId: claimCase.claimId,
|
||
dmgCaseId: claimCase.dmgCaseId,
|
||
expertiseId: claimCase.expertiseId,
|
||
};
|
||
}
|
||
|
||
if (!claimCase.claimId) {
|
||
await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId);
|
||
claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
}
|
||
if (!claimCase?.dmgCaseId) {
|
||
await this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected(
|
||
claimCaseId,
|
||
claimCase?.damage?.selectedParts ?? [],
|
||
);
|
||
claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
}
|
||
|
||
const { payload, warnings } = await this.buildFanavaranExpertisePayload({
|
||
claimCase,
|
||
clientKey,
|
||
});
|
||
this.assertFanavaranExpertisePayloadReady(payload, warnings);
|
||
|
||
const fanavaranResponse = await this.executeFanavaranExpertiseSubmit(
|
||
claimCaseId,
|
||
clientKey,
|
||
payload,
|
||
FanavaranAuditSource.AUTO_SUBMIT,
|
||
);
|
||
const expertiseId = fanavaranResponse?.Id;
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
clientKey,
|
||
claimId: claimCase?.claimId,
|
||
dmgCaseId: claimCase?.dmgCaseId,
|
||
expertiseId,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: true,
|
||
claimId: claimCase?.claimId,
|
||
dmgCaseId: claimCase?.dmgCaseId,
|
||
expertiseId,
|
||
fanavaranResponse,
|
||
};
|
||
} catch (error) {
|
||
const warning = this.extractFanavaranErrorMessage(error);
|
||
this.logger.warn(`${logPrefix} Expertise auto-submit failed: ${warning}`);
|
||
try {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
"fanavaranSync.expertise.status": "failed",
|
||
"fanavaranSync.expertise.lastTriedAt": new Date(),
|
||
"fanavaranSync.expertise.lastError": warning,
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: { clientKey, error: warning },
|
||
},
|
||
},
|
||
});
|
||
} catch (historyError) {
|
||
this.logger.error(
|
||
`${logPrefix} Failed to record Fanavaran expertise failure history`,
|
||
historyError,
|
||
);
|
||
}
|
||
return {
|
||
attempted: true,
|
||
submitted: false,
|
||
warning,
|
||
};
|
||
}
|
||
}
|
||
|
||
private withFanavaranAutoSubmitMessage(
|
||
baseMessage: string,
|
||
fanavaran: FanavaranAutoSubmitResult,
|
||
): string {
|
||
if (fanavaran.submitted) {
|
||
return `${baseMessage} The initial third-party case was sent to Fanavaran successfully.`;
|
||
}
|
||
if (fanavaran.warning) {
|
||
return `${baseMessage} ${fanavaran.warning}`;
|
||
}
|
||
return baseMessage;
|
||
}
|
||
|
||
private async getFanavaranAutoSubmitSkipReason(
|
||
claimCase: any,
|
||
): Promise<string | null> {
|
||
if (claimCase.claimId != null || claimCase.claimNo != null) {
|
||
return "Already submitted to Fanavaran";
|
||
}
|
||
|
||
if (!claimCase.blameRequestId) {
|
||
return "Blame case not linked to claim case";
|
||
}
|
||
|
||
const blameCase = await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId,
|
||
);
|
||
if (!blameCase) {
|
||
return "Blame case not found";
|
||
}
|
||
|
||
if (blameCase.type !== BlameRequestType.THIRD_PARTY) {
|
||
return "Fanavaran third-party financial submit only applies to THIRD_PARTY claims";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private async postFanavaranJson(
|
||
url: string,
|
||
payload: Record<string, unknown>,
|
||
clientKey: FanavaranClientKey,
|
||
) {
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const appToken = await this.getAppToken(profile.auth);
|
||
const authenticationToken = await this.login(appToken, profile.auth);
|
||
|
||
return await firstValueFrom(
|
||
this.httpService.post(url, payload, {
|
||
headers: {
|
||
authenticationToken,
|
||
CorpId: profile.auth.corpId,
|
||
ContractId: profile.auth.contractId,
|
||
Location: profile.auth.location,
|
||
"Content-Type": "application/json",
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
|
||
private async postFanavaranMultipart(
|
||
url: string,
|
||
content: Record<string, unknown>,
|
||
files: Array<{ path: string; fileName: string }>,
|
||
clientKey: FanavaranClientKey,
|
||
) {
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const appToken = await this.getAppToken(profile.auth);
|
||
const authenticationToken = await this.login(appToken, profile.auth);
|
||
const form = new FormData();
|
||
|
||
form.append("content", JSON.stringify(content), {
|
||
contentType: "application/json",
|
||
});
|
||
for (const file of files) {
|
||
form.append("files", createReadStream(file.path), {
|
||
filename: file.fileName,
|
||
});
|
||
}
|
||
|
||
return await firstValueFrom(
|
||
this.httpService.post(url, form, {
|
||
headers: {
|
||
...form.getHeaders(),
|
||
authenticationToken,
|
||
CorpId: profile.auth.corpId,
|
||
ContractId: profile.auth.contractId,
|
||
Location: profile.auth.location,
|
||
},
|
||
maxBodyLength: Infinity,
|
||
maxContentLength: Infinity,
|
||
}),
|
||
);
|
||
}
|
||
|
||
async autoSubmitFanavaranAttachment(
|
||
claimCaseId: string,
|
||
file: { path?: string | null; fileName?: string | null; source?: string },
|
||
options?: {
|
||
clientKey?: FanavaranClientKey;
|
||
auditSource?: FanavaranAuditSource;
|
||
},
|
||
): Promise<FanavaranAttachmentSubmitResult> {
|
||
const clientKey = options?.clientKey ?? resolveFanavaranClientKey();
|
||
const auditSource =
|
||
options?.auditSource ?? FanavaranAuditSource.AUTO_SUBMIT;
|
||
const logPrefix = `[Fanavaran ${clientKey} V2 Attachment Auto] claimCaseId=${claimCaseId}`;
|
||
const filePath = file.path ? String(file.path) : "";
|
||
const fileName = file.fileName
|
||
? String(file.fileName)
|
||
: filePath
|
||
? basename(filePath)
|
||
: "";
|
||
|
||
try {
|
||
if (!filePath || !fileName) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason: "Attachment file path or filename is missing",
|
||
};
|
||
}
|
||
if (!existsSync(filePath)) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason: `Attachment file does not exist: ${filePath}`,
|
||
fileName,
|
||
};
|
||
}
|
||
|
||
let claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
warning: "Claim case not found for Fanavaran attachment submit.",
|
||
fileName,
|
||
};
|
||
}
|
||
|
||
if (!claimCase.claimId) {
|
||
if (options?.clientKey) {
|
||
await this.executeFanavaranV2Submit(claimCaseId, clientKey);
|
||
} else {
|
||
await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId);
|
||
}
|
||
claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
}
|
||
|
||
if (!claimCase?.claimId) {
|
||
const warning =
|
||
"Fanavaran base claim is missing, so attachment was not submitted.";
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
"fanavaranSync.attachments.status": "skipped",
|
||
"fanavaranSync.attachments.lastTriedAt": new Date(),
|
||
"fanavaranSync.attachments.lastError": warning,
|
||
},
|
||
});
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason: warning,
|
||
warning,
|
||
fileName,
|
||
};
|
||
}
|
||
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/files`;
|
||
const content = {
|
||
FileName: fileName,
|
||
FileTypeId: profile.defaults.ClaimFileTypeId,
|
||
Files: [
|
||
{ FileName: fileName, FileTypeId: profile.defaults.ClaimFileTypeId },
|
||
],
|
||
};
|
||
const auditSession: FanavaranAuditSession = {
|
||
trackingCode: this.fanavaranAuditService.generateTrackingCode(),
|
||
clientKey,
|
||
source: auditSource,
|
||
claimCaseId,
|
||
};
|
||
const startedAt = Date.now();
|
||
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_ATTACHMENT,
|
||
status: FanavaranAuditStatus.STARTED,
|
||
requestUrl: url,
|
||
requestMeta: {
|
||
claimId: claimCase.claimId,
|
||
fileName,
|
||
source: file.source,
|
||
content,
|
||
},
|
||
});
|
||
|
||
const response = await this.postFanavaranMultipart(
|
||
url,
|
||
content,
|
||
[{ path: filePath, fileName }],
|
||
clientKey,
|
||
);
|
||
const fileId = response.data?.Id;
|
||
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_ATTACHMENT,
|
||
status: FanavaranAuditStatus.SUCCESS,
|
||
requestUrl: url,
|
||
httpStatus: response.status,
|
||
responseMeta: { claimId: claimCase.claimId, fileId, fileName },
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
"fanavaranSync.attachments.status": "success",
|
||
"fanavaranSync.attachments.lastTriedAt": new Date(),
|
||
"fanavaranSync.attachments.claimId": claimCase.claimId,
|
||
"fanavaranSync.attachments.response": response.data,
|
||
},
|
||
$push: {
|
||
"fanavaranSync.attachments.files": {
|
||
fileId,
|
||
fileName,
|
||
source: file.source,
|
||
response: response.data,
|
||
submittedAt: new Date(),
|
||
},
|
||
history: {
|
||
type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_SUCCEEDED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
clientKey,
|
||
claimId: claimCase.claimId,
|
||
fileId,
|
||
fileName,
|
||
source: file.source,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: true,
|
||
claimId: claimCase.claimId,
|
||
fileId,
|
||
fileName,
|
||
fanavaranResponse: response.data,
|
||
};
|
||
} catch (error) {
|
||
const warning = this.extractFanavaranErrorMessage(error);
|
||
this.logger.warn(
|
||
`${logPrefix} Attachment auto-submit failed: ${warning}`,
|
||
);
|
||
|
||
try {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
"fanavaranSync.attachments.status": "failed",
|
||
"fanavaranSync.attachments.lastTriedAt": new Date(),
|
||
"fanavaranSync.attachments.lastError": warning,
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_ATTACHMENT_AUTO_SUBMIT_FAILED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
clientKey,
|
||
error: warning,
|
||
fileName,
|
||
source: file.source,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
} catch (historyError) {
|
||
this.logger.error(
|
||
`${logPrefix} Failed to record Fanavaran attachment failure history`,
|
||
historyError,
|
||
);
|
||
}
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: false,
|
||
warning,
|
||
fileName,
|
||
};
|
||
}
|
||
}
|
||
|
||
private async executeFanavaranDamageCaseSubmit(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
selectedParts?: unknown[],
|
||
auditSource: FanavaranAuditSource = FanavaranAuditSource.AUTO_SUBMIT,
|
||
): Promise<any> {
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException("Claim case not found");
|
||
}
|
||
if (!claimCase.claimId) {
|
||
throw new BadRequestException(
|
||
"Fanavaran claimId is required before submitting damage case",
|
||
);
|
||
}
|
||
if (!claimCase.blameRequestId) {
|
||
throw new BadRequestException("Blame case not linked to claim case");
|
||
}
|
||
|
||
const blameCase = await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId,
|
||
);
|
||
if (!blameCase) {
|
||
throw new NotFoundException("Blame case not found");
|
||
}
|
||
if (blameCase.type !== BlameRequestType.THIRD_PARTY) {
|
||
throw new BadRequestException(
|
||
"Fanavaran damage-case submit only applies to THIRD_PARTY claims",
|
||
);
|
||
}
|
||
|
||
const payload = this.buildFanavaranDamageCasePayload({
|
||
claimCase,
|
||
blameCase,
|
||
selectedParts: selectedParts ?? claimCase.damage?.selectedParts ?? [],
|
||
defaults: profile.defaults,
|
||
});
|
||
const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/dmg-cases`;
|
||
const startedAt = Date.now();
|
||
const auditSession: FanavaranAuditSession = {
|
||
trackingCode: this.fanavaranAuditService.generateTrackingCode(),
|
||
clientKey,
|
||
source: auditSource,
|
||
claimCaseId,
|
||
};
|
||
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE,
|
||
status: FanavaranAuditStatus.STARTED,
|
||
requestUrl: url,
|
||
requestMeta: { claimId: claimCase.claimId, payload },
|
||
});
|
||
|
||
try {
|
||
const response = await this.postFanavaranJson(url, payload, clientKey);
|
||
const dmgCaseId = response.data?.Id;
|
||
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE,
|
||
status: FanavaranAuditStatus.SUCCESS,
|
||
requestUrl: url,
|
||
httpStatus: response.status,
|
||
responseMeta: { dmgCaseId, claimId: claimCase.claimId },
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
|
||
if (dmgCaseId !== undefined) {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
dmgCaseId,
|
||
"fanavaranSync.damageCase": {
|
||
status: "success",
|
||
lastTriedAt: new Date(),
|
||
claimId: claimCase.claimId,
|
||
dmgCaseId,
|
||
response: response.data,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
return response.data;
|
||
} catch (error) {
|
||
await this.fanavaranAuditService.recordStep({
|
||
session: auditSession,
|
||
step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE,
|
||
status: FanavaranAuditStatus.FAILURE,
|
||
requestUrl: url,
|
||
httpStatus: isAxiosError(error) ? error.response?.status : undefined,
|
||
errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
|
||
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async autoSubmitFanavaranDamageCaseOnOuterPartsSelected(
|
||
claimCaseId: string,
|
||
selectedParts: unknown[],
|
||
): Promise<FanavaranDamageCaseSubmitResult> {
|
||
const clientKey = resolveFanavaranClientKey();
|
||
const logPrefix = `[Fanavaran ${clientKey} V2 DamageCase Auto] claimCaseId=${claimCaseId}`;
|
||
|
||
try {
|
||
let claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
warning: "Claim case not found for Fanavaran damage-case submit.",
|
||
};
|
||
}
|
||
if (claimCase.dmgCaseId != null) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason: "Damage case already submitted to Fanavaran",
|
||
claimId: claimCase.claimId,
|
||
dmgCaseId: claimCase.dmgCaseId,
|
||
};
|
||
}
|
||
|
||
if (!claimCase.claimId) {
|
||
await this.autoSubmitToFanavaranV2OnClaimCreated(claimCaseId);
|
||
claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
}
|
||
|
||
if (!claimCase?.claimId) {
|
||
const warning =
|
||
"Fanavaran base claim is missing, so damage case was not submitted.";
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
"fanavaranSync.damageCase": {
|
||
status: "skipped",
|
||
lastTriedAt: new Date(),
|
||
lastError: warning,
|
||
},
|
||
},
|
||
});
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason: warning,
|
||
warning,
|
||
};
|
||
}
|
||
|
||
const fanavaranResponse = await this.executeFanavaranDamageCaseSubmit(
|
||
claimCaseId,
|
||
clientKey,
|
||
selectedParts,
|
||
);
|
||
const dmgCaseId = fanavaranResponse?.Id;
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_SUCCEEDED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
clientKey,
|
||
claimId: claimCase.claimId,
|
||
dmgCaseId,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: true,
|
||
claimId: claimCase.claimId,
|
||
dmgCaseId,
|
||
fanavaranResponse,
|
||
};
|
||
} catch (error) {
|
||
const warning = this.extractFanavaranErrorMessage(error);
|
||
this.logger.warn(
|
||
`${logPrefix} Damage-case auto-submit failed: ${warning}`,
|
||
);
|
||
|
||
try {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$set: {
|
||
"fanavaranSync.damageCase": {
|
||
status: "failed",
|
||
lastTriedAt: new Date(),
|
||
lastError: warning,
|
||
},
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_FAILED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: { clientKey, error: warning },
|
||
},
|
||
},
|
||
});
|
||
} catch (historyError) {
|
||
this.logger.error(
|
||
`${logPrefix} Failed to record Fanavaran damage-case failure history`,
|
||
historyError,
|
||
);
|
||
}
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: false,
|
||
warning,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Auto-submit the minimum third-party Fanavaran claim as soon as the local
|
||
* claim case exists. This preserves progress in Fanavaran even if parties stop
|
||
* later in the damage-assessment flow.
|
||
*/
|
||
async autoSubmitToFanavaranV2OnClaimCreated(
|
||
claimCaseId: string,
|
||
): Promise<FanavaranAutoSubmitResult> {
|
||
const clientKey = resolveFanavaranClientKey();
|
||
const logPrefix = `[Fanavaran ${clientKey} V2 Early Auto] claimCaseId=${claimCaseId}`;
|
||
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
warning:
|
||
"Claim case not found for Fanavaran early submit. Retry manually when available.",
|
||
};
|
||
}
|
||
|
||
const skipReason = await this.getFanavaranAutoSubmitSkipReason(claimCase);
|
||
if (skipReason) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason,
|
||
claimId: claimCase.claimId,
|
||
claimNo: claimCase.claimNo,
|
||
};
|
||
}
|
||
|
||
const fanavaranResponse = await this.executeFanavaranV2Submit(
|
||
claimCaseId,
|
||
clientKey,
|
||
);
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
clientKey,
|
||
claimNo: fanavaranResponse?.ClaimNo,
|
||
claimId: fanavaranResponse?.Id,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: true,
|
||
claimNo: fanavaranResponse?.ClaimNo,
|
||
claimId: fanavaranResponse?.Id,
|
||
fanavaranResponse,
|
||
};
|
||
} catch (error) {
|
||
const warning = this.extractFanavaranErrorMessage(error);
|
||
this.logger.warn(`${logPrefix} Early auto-submit failed: ${warning}`);
|
||
|
||
try {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_EARLY_AUTO_SUBMIT_FAILED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: { clientKey, error: warning },
|
||
},
|
||
},
|
||
});
|
||
} catch (historyError) {
|
||
this.logger.error(
|
||
`${logPrefix} Failed to record Fanavaran early auto-submit failure history`,
|
||
historyError,
|
||
);
|
||
}
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: false,
|
||
warning: `${warning} Initial case was not sent to Fanavaran. Retry manually via POST ${fanavaranSubmitPath(clientKey, claimCaseId)}.`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Auto-submit to Fanavaran when a claim case reaches COMPLETED.
|
||
* Uses FANAVARAN_CLIENT env (or CLIENT_ID fallback) to pick the tenant.
|
||
* Never throws — failures are returned as warnings so the main flow continues.
|
||
*/
|
||
async autoSubmitToFanavaranV2OnClaimCompleted(
|
||
claimCaseId: string,
|
||
): Promise<FanavaranAutoSubmitResult> {
|
||
const clientKey = resolveFanavaranClientKey();
|
||
const logPrefix = `[Fanavaran ${clientKey} V2 Auto] claimCaseId=${claimCaseId}`;
|
||
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimCaseId);
|
||
if (!claimCase) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
warning:
|
||
"Claim case not found for Fanavaran auto-submit. Retry manually when available.",
|
||
};
|
||
}
|
||
|
||
const skipReason = await this.getFanavaranAutoSubmitSkipReason(claimCase);
|
||
if (skipReason) {
|
||
return {
|
||
attempted: false,
|
||
submitted: false,
|
||
skipped: true,
|
||
skipReason,
|
||
claimId: claimCase.claimId,
|
||
claimNo: claimCase.claimNo,
|
||
};
|
||
}
|
||
|
||
const fanavaranResponse = await this.executeFanavaranV2Submit(
|
||
claimCaseId,
|
||
clientKey,
|
||
);
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_AUTO_SUBMIT_SUCCEEDED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
clientKey,
|
||
claimNo: fanavaranResponse?.ClaimNo,
|
||
claimId: fanavaranResponse?.Id,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: true,
|
||
claimNo: fanavaranResponse?.ClaimNo,
|
||
claimId: fanavaranResponse?.Id,
|
||
fanavaranResponse,
|
||
};
|
||
} catch (error) {
|
||
const warning = this.extractFanavaranErrorMessage(error);
|
||
this.logger.warn(`${logPrefix} Auto-submit failed: ${warning}`);
|
||
|
||
try {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
|
||
$push: {
|
||
history: {
|
||
type: "FANAVARAN_AUTO_SUBMIT_FAILED",
|
||
actor: { actorType: "system" },
|
||
timestamp: new Date(),
|
||
metadata: { clientKey, error: warning },
|
||
},
|
||
},
|
||
});
|
||
} catch (historyError) {
|
||
this.logger.error(
|
||
`${logPrefix} Failed to record Fanavaran auto-submit failure history`,
|
||
historyError,
|
||
);
|
||
}
|
||
|
||
return {
|
||
attempted: true,
|
||
submitted: false,
|
||
warning: `${warning} Case was not sent to Fanavaran. Retry manually via POST ${fanavaranSubmitPath(clientKey, claimCaseId)}.`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/** @deprecated Use autoSubmitToFanavaranV2OnClaimCompleted */
|
||
async autoSubmitToFanavaranParsianV2OnClaimCompleted(
|
||
claimCaseId: string,
|
||
): Promise<FanavaranAutoSubmitResult> {
|
||
return this.autoSubmitToFanavaranV2OnClaimCompleted(claimCaseId);
|
||
}
|
||
|
||
private extractFanavaranErrorMessage(error: unknown): string {
|
||
if (isAxiosError(error)) {
|
||
return (
|
||
error.response?.data?.message ||
|
||
error.response?.data?.Message ||
|
||
(typeof error.response?.data === "string"
|
||
? error.response.data
|
||
: undefined) ||
|
||
error.message ||
|
||
"Failed to submit data to Fanavaran API"
|
||
);
|
||
}
|
||
if (error instanceof HttpException) {
|
||
const response = error.getResponse();
|
||
if (typeof response === "string") return response;
|
||
if (response && typeof response === "object" && "message" in response) {
|
||
const message = (response as { message?: string | string[] }).message;
|
||
return Array.isArray(message) ? message.join(", ") : String(message);
|
||
}
|
||
}
|
||
if (error instanceof Error) {
|
||
return error.message;
|
||
}
|
||
return "Failed to submit data to Fanavaran API";
|
||
}
|
||
|
||
private async executeFanavaranV2Submit(
|
||
claimCaseId: string,
|
||
clientKey: FanavaranClientKey,
|
||
): Promise<any> {
|
||
const profile = getFanavaranClientProfile(clientKey);
|
||
const logPrefix = `[Fanavaran ${clientKey} V2]`;
|
||
|
||
this.logger.log(
|
||
`${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`,
|
||
);
|
||
const fanavaranData = await this.previewFanavaranSubmitV2(
|
||
claimCaseId,
|
||
clientKey,
|
||
);
|
||
this.logger.log(
|
||
`${logPrefix} Mapped data prepared:`,
|
||
JSON.stringify(fanavaranData, null, 2),
|
||
);
|
||
|
||
if (
|
||
fanavaranData.AccidentCauseId === undefined ||
|
||
fanavaranData.AccidentCauseId === null
|
||
) {
|
||
this.logger.error(
|
||
`${logPrefix} CRITICAL: AccidentCauseId is missing or null!`,
|
||
);
|
||
} else {
|
||
this.logger.log(
|
||
`${logPrefix} AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`,
|
||
);
|
||
}
|
||
|
||
const appToken = await this.getAppToken(profile.auth);
|
||
const authenticationToken = await this.login(appToken, profile.auth);
|
||
|
||
const response = await firstValueFrom(
|
||
this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, {
|
||
headers: {
|
||
authenticationToken: authenticationToken,
|
||
CorpId: profile.auth.corpId,
|
||
ContractId: profile.auth.contractId,
|
||
Location: profile.auth.location,
|
||
"Content-Type": "application/json",
|
||
},
|
||
}),
|
||
);
|
||
|
||
this.logger.log(`${logPrefix} API Response Status: ${response.status}`);
|
||
this.logger.log(
|
||
`${logPrefix} API Response Data:`,
|
||
JSON.stringify(response.data, null, 2),
|
||
);
|
||
|
||
if (response.data) {
|
||
const claimNo = response.data.ClaimNo;
|
||
const claimId = response.data.Id;
|
||
|
||
if (claimNo !== undefined || claimId !== undefined) {
|
||
const updateData: any = { $set: {} as Record<string, unknown> };
|
||
if (claimNo !== undefined) {
|
||
updateData.$set.claimNo = claimNo;
|
||
}
|
||
if (claimId !== undefined) {
|
||
updateData.$set.claimId = claimId;
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimCaseId,
|
||
updateData,
|
||
);
|
||
}
|
||
}
|
||
|
||
return response.data;
|
||
}
|
||
|
||
/** @deprecated Use executeFanavaranV2Submit(claimCaseId, "parsian") */
|
||
private async executeFanavaranParsianV2Submit(
|
||
claimCaseId: string,
|
||
): Promise<any> {
|
||
return this.executeFanavaranV2Submit(claimCaseId, "parsian");
|
||
}
|
||
|
||
/**
|
||
* Submit fanavaran data to external API
|
||
*/
|
||
async submitToFanavaran(claimRequestId: string): Promise<any> {
|
||
try {
|
||
// First, get the mapped data
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Starting submission for claimRequestId: ${claimRequestId}`,
|
||
);
|
||
const fanavaranData = await this.fanavaranSubmit(claimRequestId);
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Mapped data prepared:`,
|
||
JSON.stringify(fanavaranData, null, 2),
|
||
);
|
||
|
||
// Validate required fields before submission
|
||
if (
|
||
fanavaranData.AccidentCauseId === undefined ||
|
||
fanavaranData.AccidentCauseId === null
|
||
) {
|
||
this.logger.error(
|
||
`[Fanavaran Submit] CRITICAL: AccidentCauseId is missing or null!`,
|
||
);
|
||
this.logger.error(
|
||
`[Fanavaran Submit] This field is required by the API. Request will likely fail.`,
|
||
);
|
||
this.logger.error(
|
||
`[Fanavaran Submit] Available fields in result:`,
|
||
Object.keys(fanavaranData),
|
||
);
|
||
} else {
|
||
this.logger.log(
|
||
`[Fanavaran Submit] AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`,
|
||
);
|
||
}
|
||
|
||
// Step 1: Get appToken
|
||
this.logger.log(`[Fanavaran Submit] Step 1: Getting appToken`);
|
||
const appToken = await this.getAppToken();
|
||
this.logger.log(`[Fanavaran Submit] AppToken obtained: ${appToken}`);
|
||
|
||
// Step 2: Login to get authenticationToken
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Step 2: Logging in to get authenticationToken`,
|
||
);
|
||
const authenticationToken = await this.login(appToken);
|
||
this.logger.log(
|
||
`[Fanavaran Submit] AuthenticationToken obtained: ${authenticationToken}`,
|
||
);
|
||
|
||
// Step 3: Submit data to Fanavaran API
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Step 3: Submitting data to Fanavaran API`,
|
||
);
|
||
this.logger.log(`[Fanavaran Submit] URL: ${this.FANAVARAN_SUBMIT_URL}`);
|
||
this.logger.log(`[Fanavaran Submit] Request headers:`, {
|
||
authenticationToken: authenticationToken,
|
||
CorpId: this.CORP_ID,
|
||
ContractId: this.CONTRACT_ID,
|
||
Location: this.LOCATION,
|
||
"Content-Type": "application/json",
|
||
});
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Request body:`,
|
||
JSON.stringify(fanavaranData, null, 2),
|
||
);
|
||
|
||
const response = await firstValueFrom(
|
||
this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, {
|
||
headers: {
|
||
authenticationToken: authenticationToken,
|
||
CorpId: this.CORP_ID,
|
||
ContractId: this.CONTRACT_ID,
|
||
Location: this.LOCATION,
|
||
"Content-Type": "application/json",
|
||
},
|
||
}),
|
||
);
|
||
|
||
this.logger.log(
|
||
`[Fanavaran Submit] API Response Status: ${response.status}`,
|
||
);
|
||
this.logger.log(
|
||
`[Fanavaran Submit] API Response Headers:`,
|
||
JSON.stringify(response.headers, null, 2),
|
||
);
|
||
this.logger.log(
|
||
`[Fanavaran Submit] API Response Data:`,
|
||
JSON.stringify(response.data, null, 2),
|
||
);
|
||
this.logger.log("Successfully submitted data to Fanavaran API");
|
||
|
||
// Step 4: Save ClaimNo and Id from response to claim-request-management document
|
||
if (response.data) {
|
||
const claimNo = response.data.ClaimNo;
|
||
const claimId = response.data.Id;
|
||
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Extracted from response - ClaimNo: ${claimNo}, Id: ${claimId}`,
|
||
);
|
||
|
||
if (claimNo !== undefined || claimId !== undefined) {
|
||
const updateData: any = {
|
||
$set: {},
|
||
};
|
||
if (claimNo !== undefined) {
|
||
updateData.$set.claimNo = claimNo;
|
||
}
|
||
if (claimId !== undefined) {
|
||
updateData.$set.claimId = claimId;
|
||
}
|
||
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Updating document with:`,
|
||
JSON.stringify(updateData, null, 2),
|
||
);
|
||
await this.claimRequestManagementDbService.findAndUpdate(
|
||
claimRequestId,
|
||
updateData,
|
||
);
|
||
|
||
this.logger.log(
|
||
`Updated claim-request-management document with claimNo: ${claimNo}, claimId: ${claimId}`,
|
||
);
|
||
} else {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] No ClaimNo or Id found in response data`,
|
||
);
|
||
}
|
||
} else {
|
||
this.logger.warn(`[Fanavaran Submit] No response data received`);
|
||
}
|
||
|
||
return response.data;
|
||
} catch (error) {
|
||
this.logger.error("[Fanavaran Submit] Error submitting to Fanavaran");
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Error type:",
|
||
error?.constructor?.name,
|
||
);
|
||
|
||
if (isAxiosError(error)) {
|
||
this.logger.error("[Fanavaran Submit] Axios Error Details:");
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Error Status:",
|
||
error.response?.status,
|
||
);
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Error Status Text:",
|
||
error.response?.statusText,
|
||
);
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Error Response Headers:",
|
||
JSON.stringify(error.response?.headers, null, 2),
|
||
);
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Error Response Data:",
|
||
JSON.stringify(error.response?.data, null, 2),
|
||
);
|
||
this.logger.error("[Fanavaran Submit] Request URL:", error.config?.url);
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Request Method:",
|
||
error.config?.method,
|
||
);
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Request Headers:",
|
||
JSON.stringify(error.config?.headers, null, 2),
|
||
);
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Request Data:",
|
||
JSON.stringify(error.config?.data, null, 2),
|
||
);
|
||
|
||
const errorMessage =
|
||
error.response?.data?.message ||
|
||
error.response?.data?.Message ||
|
||
error.response?.data ||
|
||
error.message ||
|
||
"Failed to submit data to Fanavaran API";
|
||
|
||
this.logger.error(
|
||
"[Fanavaran Submit] Extracted Error Message:",
|
||
errorMessage,
|
||
);
|
||
throw new BadGatewayException(errorMessage);
|
||
} else {
|
||
this.logger.error("[Fanavaran Submit] Non-Axios Error:", error);
|
||
throw new BadGatewayException("Failed to submit data to Fanavaran API");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Create claim request from completed blame case
|
||
* Only damaged party can create claim after both parties accept expert decision
|
||
*/
|
||
async createClaimFromBlameV2(
|
||
blameRequestId: string,
|
||
currentUserId: string,
|
||
): Promise<CreateClaimFromBlameResponseDto> {
|
||
try {
|
||
// 1. Fetch blame request from new blameCases collection
|
||
const blameRequest =
|
||
await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
|
||
// 2. Validate blame status is COMPLETED
|
||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||
);
|
||
}
|
||
|
||
const parties = blameRequest.parties || [];
|
||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
||
|
||
if (isCarBody) {
|
||
// CAR_BODY: single party, no expert; first party is the damaged one who creates the claim
|
||
if (parties.length < 1) {
|
||
throw new BadRequestException("Blame request has no party");
|
||
}
|
||
const firstParty = parties.find((p) => p.role === "FIRST");
|
||
if (
|
||
!firstParty ||
|
||
String(firstParty.person?.userId) !== currentUserId
|
||
) {
|
||
throw new ForbiddenException(
|
||
"Only the first party (damaged) can create a claim from this CAR_BODY blame request",
|
||
);
|
||
}
|
||
} else {
|
||
// THIRD_PARTY: require expert decision and both parties signed
|
||
if (!blameRequest.expert?.decision) {
|
||
throw new BadRequestException(
|
||
"Cannot create claim until expert has made a decision",
|
||
);
|
||
}
|
||
|
||
const guiltyPartyId = String(
|
||
blameRequest.expert.decision.guiltyPartyId,
|
||
);
|
||
|
||
if (parties.length < 2) {
|
||
throw new BadRequestException(
|
||
"Both parties must be present in the blame request",
|
||
);
|
||
}
|
||
|
||
const allPartiesSigned = parties.every(
|
||
(p) => p.confirmation !== undefined && p.confirmation !== null,
|
||
);
|
||
|
||
if (!allPartiesSigned) {
|
||
throw new BadRequestException(
|
||
"Both parties must sign the expert decision before creating a claim",
|
||
);
|
||
}
|
||
|
||
const allPartiesAccepted = parties.every(
|
||
(p) => p.confirmation?.accepted === true,
|
||
);
|
||
|
||
if (!allPartiesAccepted) {
|
||
throw new BadRequestException(
|
||
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
|
||
);
|
||
}
|
||
|
||
const guiltyParty = parties.find((p) => {
|
||
return String(p.person?.userId) === guiltyPartyId;
|
||
});
|
||
|
||
if (
|
||
guiltyParty &&
|
||
String(guiltyParty.person?.userId) === currentUserId
|
||
) {
|
||
throw new ForbiddenException(
|
||
"You cannot create a claim as you are the guilty party",
|
||
);
|
||
}
|
||
}
|
||
|
||
// 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
|
||
const currentUserParty = parties.find(
|
||
(p) => String(p.person?.userId) === currentUserId,
|
||
);
|
||
|
||
if (!currentUserParty) {
|
||
throw new ForbiddenException(
|
||
"You are not a party in this blame request",
|
||
);
|
||
}
|
||
|
||
// 6. Validate no existing claim for this blame
|
||
const existingClaim = await this.claimCaseDbService.findOne({
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
});
|
||
|
||
if (existingClaim) {
|
||
throw new ConflictException(
|
||
"A claim request for this blame case already exists",
|
||
);
|
||
}
|
||
|
||
// 7. Generate claim number
|
||
const claimNo = await this.generateUniqueClaimNumber();
|
||
|
||
// 8. Create claim case
|
||
const ownerParty = resolveClaimOwnerParty(blameRequest);
|
||
if (!ownerParty?.person?.userId) {
|
||
throw new BadRequestException(
|
||
"Could not resolve claim owner from blame parties",
|
||
);
|
||
}
|
||
const damagedUserId = (() => {
|
||
const oid = String(ownerParty.person.userId);
|
||
const dp = (blameRequest.parties || []).find(
|
||
(p: any) => p.person?.userId && String(p.person.userId) !== oid,
|
||
);
|
||
return dp?.person?.userId ? String(dp.person.userId) : null;
|
||
})();
|
||
const newClaim = await this.claimCaseDbService.create({
|
||
requestNo: claimNo,
|
||
publicId: blameRequest.publicId,
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
blameRequestNo: blameRequest.requestNo,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
inquiries: (blameRequest as any).inquiries ?? {},
|
||
workflow: {
|
||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||
locked: false,
|
||
},
|
||
...(damagedUserId
|
||
? { damagedPartyUserId: new Types.ObjectId(damagedUserId) }
|
||
: {}),
|
||
owner: (() => {
|
||
const cid = ownerParty.person.clientId;
|
||
return {
|
||
userId: new Types.ObjectId(String(ownerParty.person.userId)),
|
||
userRole: ownerParty.role as any,
|
||
fullName: currentUserParty.person?.fullName,
|
||
userClientKey: cid ? String(cid) : undefined,
|
||
...(cid ? { clientId: new Types.ObjectId(String(cid)) } : {}),
|
||
};
|
||
})(),
|
||
history: [
|
||
{
|
||
type: "CLAIM_CREATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: currentUserParty.person?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
blameRequestId,
|
||
blamePublicId: blameRequest.publicId,
|
||
},
|
||
},
|
||
],
|
||
} as any);
|
||
|
||
this.logger.log(
|
||
`Claim created: ${newClaim._id} from blame: ${blameRequestId}`,
|
||
);
|
||
|
||
const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated(
|
||
String(newClaim._id),
|
||
);
|
||
const message = this.withFanavaranAutoSubmitMessage(
|
||
"Claim request created successfully",
|
||
fanavaran,
|
||
);
|
||
|
||
return {
|
||
claimRequestId: String(newClaim._id),
|
||
publicId: blameRequest.publicId,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
message,
|
||
fanavaran,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
"createClaimFromBlameV2 failed",
|
||
blameRequestId,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw new InternalServerErrorException(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Failed to create claim request",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame.
|
||
* Owner: damaged party `userId` (SMS / user actions), guilty party `clientId` (insurer queue).
|
||
*/
|
||
async createClaimFromBlameForExpertV2(
|
||
blameRequestId: string,
|
||
expert: { sub: string; firstName?: string; lastName?: string },
|
||
): Promise<CreateClaimFromBlameResponseDto> {
|
||
const blameRequest =
|
||
await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||
);
|
||
}
|
||
if (
|
||
!blameRequest.expertInitiated ||
|
||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||
!blameRequest.initiatedByFieldExpertId
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) {
|
||
throw new ForbiddenException(
|
||
"Only the field expert who created this blame file can create the claim.",
|
||
);
|
||
}
|
||
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
|
||
if (!ownerFields) {
|
||
throw new BadRequestException(
|
||
"Could not resolve claim owner (damaged party) from blame",
|
||
);
|
||
}
|
||
const existingClaim = await this.claimCaseDbService.findOne({
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
});
|
||
if (existingClaim) {
|
||
throw new ConflictException("A claim for this blame case already exists");
|
||
}
|
||
const claimNo = await this.generateUniqueClaimNumber();
|
||
const newClaim = await this.claimCaseDbService.create({
|
||
requestNo: claimNo,
|
||
publicId: blameRequest.publicId,
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
blameRequestNo: blameRequest.requestNo,
|
||
initiatedByFieldExpertId: new Types.ObjectId(expert.sub),
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
inquiries: (blameRequest as any).inquiries ?? {},
|
||
workflow: {
|
||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||
locked: false,
|
||
},
|
||
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
|
||
owner: {
|
||
userId: new Types.ObjectId(ownerFields.userId),
|
||
userRole: ownerFields.userRole as any,
|
||
...(ownerFields.clientId
|
||
? {
|
||
clientId: new Types.ObjectId(ownerFields.clientId),
|
||
userClientKey: ownerFields.clientId,
|
||
}
|
||
: {}),
|
||
},
|
||
history: [
|
||
{
|
||
type: "CLAIM_CREATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName:
|
||
`${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
blameRequestId,
|
||
blamePublicId: blameRequest.publicId,
|
||
createdByExpert: true,
|
||
},
|
||
},
|
||
],
|
||
} as any);
|
||
this.logger.log(
|
||
`Claim created by field expert: ${newClaim._id} from blame: ${blameRequestId}`,
|
||
);
|
||
const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated(
|
||
String(newClaim._id),
|
||
);
|
||
const message = this.withFanavaranAutoSubmitMessage(
|
||
"Claim created successfully. You can now fill claim data on behalf of the damaged party.",
|
||
fanavaran,
|
||
);
|
||
return {
|
||
claimRequestId: String(newClaim._id),
|
||
publicId: blameRequest.publicId,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
message,
|
||
fanavaran,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* True when the actor appears as a non-owner (damaged) party on the claim's
|
||
* snapshot.parties. This is the fastest, blame-free fallback: the snapshot is
|
||
* written at claim-creation time and always holds the correct user IDs.
|
||
*
|
||
* Logic:
|
||
* - Any party whose person.userId is in linkedUserIds and is NOT the claim
|
||
* owner (guilty party) is considered the damaged party.
|
||
* - If owner.userId is unknown we accept any matching party (CAR_BODY flows).
|
||
*/
|
||
private claimSnapshotPartyMatchesAsDamagedParty(
|
||
claim: any,
|
||
linkedUserIdStrings: string[],
|
||
): boolean {
|
||
const parties: any[] = claim?.snapshot?.parties ?? [];
|
||
if (!parties.length) return false;
|
||
|
||
const ownerIdStr = claim?.owner?.userId ? String(claim.owner.userId) : null;
|
||
|
||
for (const party of parties) {
|
||
const partyUserId = party?.person?.userId;
|
||
if (partyUserId == null) continue;
|
||
const partyStr = String(partyUserId);
|
||
|
||
// Must be this actor
|
||
if (!linkedUserIdStrings.some((id) => id === partyStr)) continue;
|
||
|
||
// Must NOT be the guilty-owner (unless owner is unknown — CAR_BODY)
|
||
if (ownerIdStr && partyStr === ownerIdStr) continue;
|
||
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private async assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase: any,
|
||
effectiveUserId: string,
|
||
actor?: { username?: string },
|
||
message = "Only the damaged party can perform this action on the claim",
|
||
): Promise<void> {
|
||
const linkedUserIds = await resolveLinkedUserIdStrings(this.userDbService, {
|
||
sub: effectiveUserId,
|
||
username: actor?.username,
|
||
});
|
||
const actorCtx = { sub: effectiveUserId, username: actor?.username };
|
||
|
||
// 0. damagedPartyUserId stored directly on the claim (fastest, most reliable)
|
||
if (ownerUserIdMatchesActor(claimCase?.damagedPartyUserId, linkedUserIds)) {
|
||
return;
|
||
}
|
||
|
||
// 1. Owner (= guilty party for THIRD_PARTY) — kept for backward compat
|
||
// on CAR_BODY where owner IS the damaged party.
|
||
if (ownerUserIdMatchesActor(claimCase?.owner?.userId, linkedUserIds)) {
|
||
return;
|
||
}
|
||
|
||
// 2. Blame-based resolution (primary path for THIRD_PARTY)
|
||
if (claimCase?.blameRequestId) {
|
||
const blame = await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId.toString(),
|
||
);
|
||
if (
|
||
blame &&
|
||
blameDamagedPartyMatchesUser(blame, actorCtx, linkedUserIds)
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 3. Snapshot-party fallback: claim.snapshot.parties is written at creation
|
||
// and is reliable even when blame lookup fails or userId mapping differs.
|
||
if (
|
||
this.claimSnapshotPartyMatchesAsDamagedParty(claimCase, linkedUserIds)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
throw new ForbiddenException(message);
|
||
}
|
||
|
||
private async assertActorCanViewClaimV2(
|
||
claim: any,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string; username?: string },
|
||
): Promise<void> {
|
||
const linkedUserIds = await resolveLinkedUserIdStrings(this.userDbService, {
|
||
sub: currentUserId,
|
||
username: actor?.username,
|
||
});
|
||
|
||
if (ownerUserIdMatchesActor(claim.owner?.userId, linkedUserIds)) {
|
||
return;
|
||
}
|
||
|
||
// damagedPartyUserId is set at claim-creation time — the most direct check.
|
||
if (ownerUserIdMatchesActor(claim.damagedPartyUserId, linkedUserIds)) {
|
||
return;
|
||
}
|
||
|
||
if (!actor?.role || actor.role === RoleEnum.USER) {
|
||
const userCtx = { sub: currentUserId, username: actor?.username };
|
||
|
||
// Any party on the linked blame can VIEW the claim.
|
||
// (Mutation guards use assertEffectiveUserIsDamagedPartyOnClaim which is
|
||
// stricter and only allows the damaged party to act.)
|
||
if (claim.blameRequestId) {
|
||
const blame = await this.blameRequestDbService.findById(
|
||
claim.blameRequestId.toString(),
|
||
);
|
||
if (blame) {
|
||
const parties: any[] = (blame as any).parties ?? [];
|
||
const isParty = parties.some((p: any) =>
|
||
partyPersonMatchesUser(p?.person, userCtx, linkedUserIds),
|
||
);
|
||
if (isParty) return;
|
||
}
|
||
}
|
||
|
||
// Snapshot-party fallback (reliable for claims with snapshot written).
|
||
if (this.claimSnapshotPartyMatchesAsDamagedParty(claim, linkedUserIds)) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
||
const blame = claim.blameRequestId
|
||
? await this.blameRequestDbService.findById(
|
||
claim.blameRequestId.toString(),
|
||
)
|
||
: null;
|
||
if (
|
||
claimCaseInitiatedByFieldExpert(claim, { sub: currentUserId }, blame)
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) {
|
||
const blame = await this.blameRequestDbService.findById(
|
||
claim.blameRequestId.toString(),
|
||
);
|
||
if (
|
||
blame?.registrarInitiated &&
|
||
blame.initiatedByRegistrarId &&
|
||
String(blame.initiatedByRegistrarId) === currentUserId
|
||
) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
throw new ForbiddenException("You do not have access to this claim");
|
||
}
|
||
|
||
/**
|
||
* Resolve effective user id for claim operations.
|
||
* For FIELD_EXPERT acting on expert-initiated IN_PERSON claim, returns the claim owner's id; otherwise returns currentUserId.
|
||
*/
|
||
private async resolveClaimEffectiveUserId(
|
||
claimCase: any,
|
||
currentUserId: string,
|
||
actorRole?: string,
|
||
): Promise<string> {
|
||
const isFieldLikeRole = [
|
||
RoleEnum.FIELD_EXPERT,
|
||
RoleEnum.REGISTRAR,
|
||
RoleEnum.FILE_MAKER,
|
||
RoleEnum.FILE_REVIEWER,
|
||
].includes(actorRole as any);
|
||
|
||
if (!isFieldLikeRole || !claimCase?.blameRequestId) {
|
||
return currentUserId;
|
||
}
|
||
const blameRequest = await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId.toString(),
|
||
);
|
||
if (!blameRequest || blameRequest.creationMethod !== "IN_PERSON")
|
||
return currentUserId;
|
||
if (actorRole === RoleEnum.FIELD_EXPERT || actorRole === RoleEnum.FILE_MAKER) {
|
||
if (
|
||
!blameRequest.expertInitiated ||
|
||
!blameRequest.initiatedByFieldExpertId ||
|
||
String(blameRequest.initiatedByFieldExpertId) !== currentUserId
|
||
) {
|
||
return currentUserId;
|
||
}
|
||
} else if (actorRole === RoleEnum.REGISTRAR) {
|
||
if (
|
||
!blameRequest.registrarInitiated ||
|
||
!blameRequest.initiatedByRegistrarId ||
|
||
String(blameRequest.initiatedByRegistrarId) !== currentUserId
|
||
) {
|
||
return currentUserId;
|
||
}
|
||
}
|
||
// FILE_REVIEWER: no initiator-id check — they pick up any IN_PERSON expert
|
||
// file after the FileMaker seals it, so we resolve the owner unconditionally.
|
||
return (
|
||
resolveDamagedPartyUserId(blameRequest) ??
|
||
(claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId)
|
||
);
|
||
}
|
||
|
||
async createClaimFromBlameForRegistrarV1(
|
||
blameRequestId: string,
|
||
registrar: { sub: string; firstName?: string; lastName?: string },
|
||
): Promise<CreateClaimFromBlameResponseDto> {
|
||
const blameRequest =
|
||
await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) throw new NotFoundException("Blame request not found");
|
||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||
);
|
||
}
|
||
if (
|
||
!blameRequest.registrarInitiated ||
|
||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||
!blameRequest.initiatedByRegistrarId
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for registrar-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) {
|
||
throw new ForbiddenException(
|
||
"Only the registrar who created this blame file can create the claim.",
|
||
);
|
||
}
|
||
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
|
||
if (!ownerFields) {
|
||
throw new BadRequestException(
|
||
"Could not resolve claim owner (damaged party) from blame",
|
||
);
|
||
}
|
||
const existingClaim = await this.claimCaseDbService.findOne({
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
});
|
||
if (existingClaim)
|
||
throw new ConflictException("A claim for this blame case already exists");
|
||
const claimNo = await this.generateUniqueClaimNumber();
|
||
const newClaim = await this.claimCaseDbService.create({
|
||
requestNo: claimNo,
|
||
publicId: blameRequest.publicId,
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
blameRequestNo: blameRequest.requestNo,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
inquiries: (blameRequest as any).inquiries ?? {},
|
||
workflow: {
|
||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||
locked: false,
|
||
},
|
||
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
|
||
owner: {
|
||
userId: new Types.ObjectId(ownerFields.userId),
|
||
userRole: ownerFields.userRole as any,
|
||
...(ownerFields.clientId
|
||
? {
|
||
clientId: new Types.ObjectId(ownerFields.clientId),
|
||
userClientKey: ownerFields.clientId,
|
||
}
|
||
: {}),
|
||
},
|
||
createdByRegistrarId: new Types.ObjectId(registrar.sub),
|
||
history: [
|
||
{
|
||
type: "CLAIM_CREATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(registrar.sub),
|
||
actorName:
|
||
`${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(),
|
||
actorType: "registrar",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
blameRequestId,
|
||
blamePublicId: blameRequest.publicId,
|
||
createdByRegistrar: true,
|
||
},
|
||
},
|
||
],
|
||
} as any);
|
||
const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated(
|
||
String(newClaim._id),
|
||
);
|
||
const message = this.withFanavaranAutoSubmitMessage(
|
||
"Claim created successfully. Registrar can now fill claim data.",
|
||
fanavaran,
|
||
);
|
||
return {
|
||
claimRequestId: String(newClaim._id),
|
||
publicId: blameRequest.publicId,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
message,
|
||
fanavaran,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2: Registrar creates a claim from a registrar-initiated IN_PERSON completed blame.
|
||
* Mirrors createClaimFromBlameForRegistrarV1 but uses V2 naming conventions.
|
||
*/
|
||
async createClaimFromBlameForRegistrarV2(
|
||
blameRequestId: string,
|
||
registrar: { sub: string; firstName?: string; lastName?: string },
|
||
): Promise<CreateClaimFromBlameResponseDto> {
|
||
const blameRequest =
|
||
await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) throw new NotFoundException("Blame request not found");
|
||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||
);
|
||
}
|
||
if (
|
||
!blameRequest.registrarInitiated ||
|
||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||
!blameRequest.initiatedByRegistrarId
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for registrar-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) {
|
||
throw new ForbiddenException(
|
||
"Only the registrar who created this blame file can create the claim.",
|
||
);
|
||
}
|
||
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
|
||
if (!ownerFields) {
|
||
throw new BadRequestException(
|
||
"Could not resolve claim owner (damaged party) from blame",
|
||
);
|
||
}
|
||
const existingClaim = await this.claimCaseDbService.findOne({
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
});
|
||
if (existingClaim)
|
||
throw new ConflictException("A claim for this blame case already exists");
|
||
const claimNo = await this.generateUniqueClaimNumber();
|
||
const newClaim = await this.claimCaseDbService.create({
|
||
requestNo: claimNo,
|
||
publicId: blameRequest.publicId,
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
blameRequestNo: blameRequest.requestNo,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
inquiries: (blameRequest as any).inquiries ?? {},
|
||
workflow: {
|
||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||
locked: false,
|
||
},
|
||
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
|
||
owner: {
|
||
userId: new Types.ObjectId(ownerFields.userId),
|
||
userRole: ownerFields.userRole as any,
|
||
...(ownerFields.clientId
|
||
? {
|
||
clientId: new Types.ObjectId(ownerFields.clientId),
|
||
userClientKey: ownerFields.clientId,
|
||
}
|
||
: {}),
|
||
},
|
||
createdByRegistrarId: new Types.ObjectId(registrar.sub),
|
||
history: [
|
||
{
|
||
type: "CLAIM_CREATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(registrar.sub),
|
||
actorName:
|
||
`${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(),
|
||
actorType: "registrar",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
blameRequestId,
|
||
blamePublicId: blameRequest.publicId,
|
||
createdByRegistrar: true,
|
||
},
|
||
},
|
||
],
|
||
} as any);
|
||
const fanavaran = await this.autoSubmitToFanavaranV2OnClaimCreated(
|
||
String(newClaim._id),
|
||
);
|
||
const message = this.withFanavaranAutoSubmitMessage(
|
||
"Claim created successfully. Registrar can now fill claim data on behalf of the damaged party.",
|
||
fanavaran,
|
||
);
|
||
return {
|
||
claimRequestId: String(newClaim._id),
|
||
publicId: blameRequest.publicId,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
message,
|
||
fanavaran,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
|
||
*
|
||
* @param claimRequestId - The claim case ID
|
||
* @param body - Selected outer parts
|
||
* @param currentUserId - The current user's ID
|
||
* @returns Response with updated claim information
|
||
*
|
||
* Validations:
|
||
* - Claim case must exist
|
||
* - User must be the claim owner (damaged party)
|
||
* - Current workflow step must be CLAIM_CREATED (step 1)
|
||
* - Selected parts array must not be empty
|
||
* - Parts must not have been selected previously
|
||
*/
|
||
async selectOuterPartsV2(
|
||
claimRequestId: string,
|
||
body: SelectOuterPartsV2Dto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||
try {
|
||
// 1. Validate claim exists
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner (damaged party) can select damaged parts",
|
||
);
|
||
|
||
// 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS)
|
||
if (
|
||
claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS
|
||
) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OUTER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
|
||
// 4. Validate outer parts haven't been selected yet
|
||
if (
|
||
claimCase.damage?.selectedParts &&
|
||
claimCase.damage.selectedParts.length > 0
|
||
) {
|
||
throw new ConflictException(
|
||
"Outer parts have already been selected for this claim",
|
||
);
|
||
}
|
||
|
||
// 5. Validate by selected car type and resolve selected parts by ids/keys
|
||
const selectedCarType = (body as any)?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
if (!selectedCarType || !OUTER_PARTS_BY_CAR_TYPE[selectedCarType]) {
|
||
throw new BadRequestException(
|
||
"Vehicle type is required before selecting outer parts.",
|
||
);
|
||
}
|
||
|
||
const catalog = OUTER_PARTS_BY_CAR_TYPE[selectedCarType];
|
||
const byId = new Map<number, OuterPartCatalogItem>(
|
||
catalog.map((p) => [p.id, p]),
|
||
);
|
||
const byKey = new Map<string, OuterPartCatalogItem>(
|
||
catalog.map((p) => [p.key, p]),
|
||
);
|
||
|
||
const selectedFromIds: OuterPartCatalogItem[] = [];
|
||
if (
|
||
Array.isArray((body as any)?.selectedPartIds) &&
|
||
(body as any).selectedPartIds.length > 0
|
||
) {
|
||
for (const id of (body as any).selectedPartIds as number[]) {
|
||
const item = byId.get(id);
|
||
if (!item) {
|
||
throw new BadRequestException(
|
||
`Invalid outer part id for ${selectedCarType}: ${id}`,
|
||
);
|
||
}
|
||
selectedFromIds.push(item);
|
||
}
|
||
}
|
||
|
||
// Backward compatibility with selectedParts + legacy carPartDamage
|
||
let selectedParts = Array.isArray((body as any)?.selectedParts)
|
||
? ((body as any).selectedParts as string[])
|
||
: [];
|
||
if (selectedParts.length === 0 && (body as any)?.carPartDamage) {
|
||
this.assertCarPartDamageAtMostTwoOfFourSides(
|
||
(body as any).carPartDamage as CarDamagePartDto,
|
||
);
|
||
selectedParts = this.carDamagePartDtoToOuterPartSlugs(
|
||
(body as any).carPartDamage as CarDamagePartDto,
|
||
);
|
||
}
|
||
const selectedFromKeys: OuterPartCatalogItem[] = [];
|
||
for (const key of selectedParts) {
|
||
const item = byKey.get(key);
|
||
if (!item) {
|
||
throw new BadRequestException(
|
||
`Invalid outer part key for ${selectedCarType}: ${key}`,
|
||
);
|
||
}
|
||
selectedFromKeys.push(item);
|
||
}
|
||
|
||
const selectedItems =
|
||
selectedFromIds.length > 0 ? selectedFromIds : selectedFromKeys;
|
||
if (selectedItems.length === 0) {
|
||
throw new BadRequestException(
|
||
"selectedPartIds or selectedParts is required and must contain at least one item",
|
||
);
|
||
}
|
||
|
||
// DISABLED: At most two non-top sides allowed
|
||
// const sideSet = new Set(
|
||
// selectedItems
|
||
// .map((p) => p.side)
|
||
// .filter((s) => s !== "top"),
|
||
// );
|
||
// if (sideSet.size > 2) {
|
||
// throw new BadRequestException(
|
||
// `At most two of left/right/front/back can be selected. Selected: ${[...sideSet].join(", ")}`,
|
||
// );
|
||
// }
|
||
|
||
const selectedPartDocs = selectedItems.map((p) =>
|
||
catalogItemToSelectedPart(p, catalog),
|
||
);
|
||
const damagedPartsInitial = selectedPartDocs.map((p) => ({
|
||
id: p.id ?? undefined,
|
||
name: p.name,
|
||
side: p.side,
|
||
label_fa: p.label_fa,
|
||
...(p.catalogKey ? { catalogKey: p.catalogKey } : {}),
|
||
}));
|
||
const selectedPartIds = selectedItems.map((p) => p.id);
|
||
|
||
// 6. Update claim case with selected parts and move to next step
|
||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
{
|
||
"damage.selectedParts": selectedPartDocs,
|
||
"media.damagedParts": damagedPartsInitial,
|
||
"vehicle.carType": selectedCarType,
|
||
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
$unset: { "damage.selectedOuterParts": "" },
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
history: {
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
selectedParts: selectedPartDocs,
|
||
selectedPartIds,
|
||
carType: selectedCarType,
|
||
partsCount: selectedPartDocs?.length,
|
||
description: `User selected ${selectedPartDocs?.length} damaged outer parts`,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new InternalServerErrorException("Failed to update claim case");
|
||
}
|
||
|
||
this.logger.log(
|
||
`Outer parts selected for claim ${claimRequestId}: ${selectedPartDocs?.length} parts`,
|
||
);
|
||
|
||
const fanavaranDamageCase =
|
||
await this.autoSubmitFanavaranDamageCaseOnOuterPartsSelected(
|
||
claimRequestId,
|
||
selectedPartDocs,
|
||
);
|
||
|
||
let message =
|
||
"Outer parts selected successfully. Please proceed to select other parts and provide bank information.";
|
||
if (fanavaranDamageCase.submitted) {
|
||
message += " The damage case was sent to Fanavaran successfully.";
|
||
} else if (fanavaranDamageCase.warning) {
|
||
message += ` ${fanavaranDamageCase.warning}`;
|
||
}
|
||
|
||
// 7. Return response
|
||
return {
|
||
claimRequestId: updatedClaim._id.toString(),
|
||
publicId: updatedClaim.publicId,
|
||
selectedParts: selectedPartDocs,
|
||
selectedPartIds,
|
||
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
message,
|
||
fanavaranDamageCase,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
"selectOuterPartsV2 failed",
|
||
claimRequestId,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw new InternalServerErrorException(
|
||
error instanceof Error ? error.message : "Failed to select outer parts",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Select other damaged parts and provide bank information (Step 3 of claim workflow)
|
||
*
|
||
* @param claimRequestId - The claim case ID
|
||
* @param body - Other parts, Sheba number, and national code
|
||
* @param currentUserId - The current user's ID
|
||
* @returns Response with updated claim information
|
||
*
|
||
* Validations:
|
||
* - Claim case must exist
|
||
* - User must be the claim owner (damaged party)
|
||
* - Current workflow step must be SELECT_OTHER_PARTS (step 3)
|
||
* - Other parts and bank info must not have been submitted previously
|
||
*/
|
||
async selectOtherPartsV2(
|
||
claimRequestId: string,
|
||
body: SelectOtherPartsV2Dto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
file?: Express.Multer.File,
|
||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||
try {
|
||
// 1. Validate claim exists
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
// 2. Validate user is the damaged party (or field expert for IN_PERSON claim)
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner (damaged party) can submit other parts and bank information",
|
||
);
|
||
|
||
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
|
||
if (
|
||
claimCase.workflow?.currentStep !==
|
||
ClaimWorkflowStep.SELECT_OTHER_PARTS &&
|
||
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
|
||
) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OTHER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
|
||
// 4. Validate other parts and bank info haven't been submitted yet
|
||
if (
|
||
claimCase.money?.sheba &&
|
||
claimCase.money?.nationalCodeOfInsurer &&
|
||
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
|
||
) {
|
||
throw new ConflictException(
|
||
"Bank information has already been submitted for this claim",
|
||
);
|
||
}
|
||
|
||
// 5. Prepare data
|
||
// Backward compatibility aliases:
|
||
// - shebaNumber <- sheba
|
||
// - nationalCodeOfOwner <- nationalCodeOfInsurer
|
||
let otherParts = Array.isArray((body as any)?.otherParts)
|
||
? (body as any).otherParts
|
||
: [];
|
||
// Multipart clients may send otherParts as JSON string
|
||
if (
|
||
!Array.isArray((body as any)?.otherParts) &&
|
||
typeof (body as any)?.otherParts === "string"
|
||
) {
|
||
try {
|
||
const parsed = JSON.parse((body as any).otherParts);
|
||
otherParts = Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
otherParts = [];
|
||
}
|
||
}
|
||
const shebaInput = String(
|
||
((body as any).shebaNumber ?? (body as any).sheba ?? "") as string,
|
||
).replace(/\s/g, "");
|
||
|
||
const shebaDigits = shebaInput.replace(/^IR/i, "");
|
||
const shebaNumber = `IR${shebaDigits}`;
|
||
const nationalCode = String(
|
||
((body as any).nationalCodeOfOwner ??
|
||
(body as any).nationalCodeOfInsurer ??
|
||
"") as string,
|
||
).replace(/\s/g, "");
|
||
if (!/^[0-9]{24}$/.test(shebaDigits)) {
|
||
throw new BadRequestException(
|
||
"shebaNumber is required and must be valid (IR + 24 digits or only 24 digits)",
|
||
);
|
||
}
|
||
if (!/^[0-9]{10}$/.test(nationalCode)) {
|
||
throw new BadRequestException(
|
||
"nationalCodeOfOwner is required and must be exactly 10 digits",
|
||
);
|
||
}
|
||
|
||
const blameRequest = claimCase.blameRequestId
|
||
? await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId.toString(),
|
||
)
|
||
: null;
|
||
const ownershipPlate = this.resolveOwnershipPlateForClaim(
|
||
claimCase,
|
||
blameRequest,
|
||
);
|
||
if (!ownershipPlate) {
|
||
throw new BadRequestException(
|
||
"Could not resolve vehicle plate for ownership validation.",
|
||
);
|
||
}
|
||
|
||
let step = claimCase.workflow?.currentStep;
|
||
|
||
// External inquiry checks before moving workflow:
|
||
// 1) ownership check for nationalCode + plate
|
||
// await this.sandHubService.getCarOwnershipInfo(ownershipPlate, nationalCode);
|
||
// 2) sheba check for nationalCode + sheba
|
||
await this.sandHubService.getShebaValidation(nationalCode, shebaNumber);
|
||
|
||
const updatePayload: any = {
|
||
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
|
||
"money.sheba": shebaNumber,
|
||
"money.nationalCodeOfInsurer": nationalCode,
|
||
|
||
status: ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
history: {
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
otherParts,
|
||
otherPartsCount: otherParts.length,
|
||
hasBankInfo: true,
|
||
hasGreenCardUpload: !!file,
|
||
description: `User selected ${otherParts.length} other damaged parts and provided bank information`,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
if (file) {
|
||
const existingGreen =
|
||
(claimCase.requiredDocuments as any)?.get?.(
|
||
ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||
) ||
|
||
(claimCase.requiredDocuments as any)?.[
|
||
ClaimRequiredDocumentType.CAR_GREEN_CARD
|
||
];
|
||
if (existingGreen?.uploaded) {
|
||
throw new ConflictException(
|
||
"car_green_card has already been uploaded for this claim",
|
||
);
|
||
}
|
||
const docRef = await this.claimRequiredDocumentDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: new Types.ObjectId(claimRequestId),
|
||
documentType: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||
uploadedAt: new Date(),
|
||
});
|
||
updatePayload[
|
||
`requiredDocuments.${ClaimRequiredDocumentType.CAR_GREEN_CARD}`
|
||
] = {
|
||
fileId: docRef._id,
|
||
filePath: file.path,
|
||
fileName: file.filename,
|
||
uploaded: true,
|
||
uploadedAt: new Date(),
|
||
};
|
||
}
|
||
|
||
// 6. Update claim case with other parts, bank info and optional green card file
|
||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
updatePayload,
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new InternalServerErrorException("Failed to update claim case");
|
||
}
|
||
|
||
this.logger.log(
|
||
`Other parts and bank info submitted for claim ${claimRequestId}: ${otherParts.length} parts`,
|
||
);
|
||
|
||
// 7. Mask sensitive data for response
|
||
const maskedSheba = `IR${shebaDigits.slice(0, 4)}************${shebaDigits.slice(-4)}`;
|
||
const maskedNationalCode = `${nationalCode.slice(0, 2)}******${nationalCode.slice(-2)}`;
|
||
|
||
// 8. Return response
|
||
return {
|
||
claimRequestId: updatedClaim._id.toString(),
|
||
publicId: updatedClaim.publicId,
|
||
otherParts: otherParts,
|
||
shebaNumber: maskedSheba,
|
||
nationalCodeOfOwner: maskedNationalCode,
|
||
currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
message:
|
||
"Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.",
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
"selectOtherPartsV2 failed",
|
||
claimRequestId,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw new InternalServerErrorException(
|
||
error instanceof Error ? error.message : "Failed to select other parts",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Get capture requirements for claim (documents, angles, parts)
|
||
*/
|
||
async getCaptureRequirementsV2(
|
||
claimRequestId: string,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner can view capture requirements",
|
||
);
|
||
|
||
// Build car-type aware outer-parts lookup (complete source: static catalog).
|
||
// Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the
|
||
// map below is keyed on the full `key` (e.g. `left_backfender`).
|
||
const selectedCarType = claimCase.vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const catalogForType =
|
||
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
|
||
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
|
||
: this.getOuterPartsRawCatalog();
|
||
const catalogByKey = new Map<string, OuterPartCatalogItem>();
|
||
for (const item of catalogForType) {
|
||
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
|
||
}
|
||
|
||
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
|
||
const blameRequest = claimCase.blameRequestId
|
||
? await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId.toString(),
|
||
)
|
||
: null;
|
||
const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY;
|
||
const requiredDocsDefinition = [
|
||
{
|
||
key: "damaged_driving_license_front",
|
||
label_fa: "گواهینامه طرف آسیبدیده - جلو",
|
||
label_en: "Damaged Party License - Front",
|
||
category: "damaged_party",
|
||
},
|
||
{
|
||
key: "damaged_driving_license_back",
|
||
label_fa: "گواهینامه طرف آسیبدیده - پشت",
|
||
label_en: "Damaged Party License - Back",
|
||
category: "damaged_party",
|
||
},
|
||
{
|
||
key: "damaged_chassis_number",
|
||
label_fa: "شماره شاسی",
|
||
label_en: "Chassis Number",
|
||
category: "damaged_party",
|
||
},
|
||
{
|
||
key: "damaged_engine_photo",
|
||
label_fa: "عکس موتور",
|
||
label_en: "Engine Photo",
|
||
category: "damaged_party",
|
||
},
|
||
{
|
||
key: "damaged_car_card_front",
|
||
label_fa: "کارت خودرو آسیبدیده - جلو",
|
||
label_en: "Damaged Car Card - Front",
|
||
category: "damaged_party",
|
||
},
|
||
{
|
||
key: "damaged_car_card_back",
|
||
label_fa: "کارت خودرو آسیبدیده - پشت",
|
||
label_en: "Damaged Car Card - Back",
|
||
category: "damaged_party",
|
||
},
|
||
{
|
||
key: "damaged_metal_plate",
|
||
label_fa: "پلاک فلزی آسیبدیده",
|
||
label_en: "Damaged Metal Plate",
|
||
category: "damaged_party",
|
||
},
|
||
{
|
||
key: "car_green_card",
|
||
label_fa: "کارت سبز خودرو",
|
||
label_en: "Car Green Card",
|
||
category: "damaged_party",
|
||
},
|
||
...(isCarBody
|
||
? []
|
||
: [
|
||
{
|
||
key: "guilty_driving_license_front",
|
||
label_fa: "گواهینامه طرف مقصر - جلو",
|
||
label_en: "Guilty Party License - Front",
|
||
category: "guilty_party",
|
||
},
|
||
{
|
||
key: "guilty_driving_license_back",
|
||
label_fa: "گواهینامه طرف مقصر - پشت",
|
||
label_en: "Guilty Party License - Back",
|
||
category: "guilty_party",
|
||
},
|
||
{
|
||
key: "guilty_car_card_front",
|
||
label_fa: "کارت خودرو مقصر - جلو",
|
||
label_en: "Guilty Car Card - Front",
|
||
category: "guilty_party",
|
||
},
|
||
{
|
||
key: "guilty_car_card_back",
|
||
label_fa: "کارت خودرو مقصر - پشت",
|
||
label_en: "Guilty Car Card - Back",
|
||
category: "guilty_party",
|
||
},
|
||
{
|
||
key: "guilty_metal_plate",
|
||
label_fa: "پلاک فلزی مقصر",
|
||
label_en: "Guilty Metal Plate",
|
||
category: "guilty_party",
|
||
},
|
||
]),
|
||
];
|
||
|
||
const requiredDocuments = requiredDocsDefinition.map((doc) => {
|
||
const docData = (claimCase.requiredDocuments as any)?.get?.(doc.key);
|
||
return {
|
||
key: doc.key,
|
||
label_fa: doc.label_fa,
|
||
label_en: doc.label_en,
|
||
category: doc.category,
|
||
uploaded: docData?.uploaded || false,
|
||
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
|
||
};
|
||
});
|
||
|
||
const hasCapture = (data: any, key: string) =>
|
||
data && (data instanceof Map ? data.get(key) : data[key]);
|
||
|
||
// Car angles
|
||
const carAngles = [
|
||
{ key: "front", label_fa: "جلوی ماشین", label_en: "Front of the car" },
|
||
{ key: "back", label_fa: "عقب ماشین", label_en: "Back of the car" },
|
||
{ key: "left", label_fa: "چپ ماشین", label_en: "Left of the car" },
|
||
{ key: "right", label_fa: "راست ماشین", label_en: "Right of the car" },
|
||
].map((angle) => ({
|
||
key: angle.key,
|
||
label_fa: angle.label_fa,
|
||
label_en: angle.label_en,
|
||
captured: hasClaimCarAngleCapture(
|
||
claimCase.media?.carAngles as any,
|
||
claimCase.media?.damagedParts as any,
|
||
angle.key as ClaimCarAngleKey,
|
||
),
|
||
}));
|
||
|
||
const carType = claimCase.vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const selectedNorm = normalizeDamageSelectedParts(
|
||
claimCase.damage?.selectedParts,
|
||
carType,
|
||
(claimCase.damage as any)?.selectedOuterParts,
|
||
);
|
||
|
||
const damagedParts = selectedNorm.map((sp) => {
|
||
const catalogMeta = sp.catalogKey
|
||
? catalogByKey.get(sp.catalogKey)
|
||
: sp.name
|
||
? catalogByKey.get(catalogLikeKeyFromPart(sp))
|
||
: undefined;
|
||
const labelEnName = sp.name
|
||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||
.replace(/_/g, " ")
|
||
.trim()
|
||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||
return {
|
||
id: sp.id ?? catalogMeta?.id,
|
||
key: sp.name,
|
||
name: sp.name,
|
||
side: sp.side,
|
||
label_fa: sp.label_fa,
|
||
label_en: labelEnName,
|
||
captured: hasDamagedPartCapture(
|
||
claimCase.media?.damagedParts as any,
|
||
ck,
|
||
selectedNorm,
|
||
),
|
||
};
|
||
});
|
||
|
||
// Calculate progress
|
||
const documentsUploaded = requiredDocuments.filter(
|
||
(d) => d.uploaded,
|
||
).length;
|
||
const anglesCaptured = carAngles.filter((a) => a.captured).length;
|
||
const partsCaptured = damagedParts.filter((p) => p.captured).length;
|
||
|
||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||
const capturePhaseDocsRemaining =
|
||
captureProgress.capturePhaseDocsRemaining;
|
||
const partsRemaining = Math.max(
|
||
0,
|
||
captureProgress.partsTotal - captureProgress.partsCaptured,
|
||
);
|
||
const anglesRemaining = Math.max(
|
||
0,
|
||
captureProgress.anglesTotal - captureProgress.anglesCaptured,
|
||
);
|
||
const postCaptureDocsRemaining = requiredDocuments.filter(
|
||
(d) => !d.preferUploadDuringCapture && !d.uploaded,
|
||
).length;
|
||
|
||
const totalRemaining =
|
||
partsRemaining + anglesRemaining + capturePhaseDocsRemaining;
|
||
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
publicId: claimCase.publicId,
|
||
currentStep: claimCase.workflow?.currentStep || "",
|
||
captureSequencePhase: captureProgress.sequencePhase,
|
||
captureSequenceHint: capturePhaseSequenceMessage(
|
||
captureProgress.sequencePhase,
|
||
),
|
||
requiredDocuments,
|
||
carAngles,
|
||
damagedParts,
|
||
totalRemaining,
|
||
progress: {
|
||
documentsUploaded,
|
||
documentsTotal: requiredDocuments.length,
|
||
anglesCaptured,
|
||
anglesTotal: carAngles.length,
|
||
partsCaptured,
|
||
partsTotal: damagedParts.length,
|
||
capturePhaseDocsRemaining,
|
||
postCaptureDocumentsRemaining: postCaptureDocsRemaining,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error("getCaptureRequirementsV2 failed", error);
|
||
throw new InternalServerErrorException(
|
||
"Failed to get capture requirements",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* When the owner has uploaded every expert-requested document/part (or acknowledged description-only resend),
|
||
* move the claim back to the damage expert queue (same posture as after initial submission).
|
||
*/
|
||
private async tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId: string,
|
||
actorUserId: string,
|
||
ownerDisplayName: string,
|
||
): Promise<void> {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim || !canFinalizeExpertResend(claim)) {
|
||
return;
|
||
}
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
"evaluation.damageExpertResend.fulfilledAt": new Date(),
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "EXPERT_RESEND_FULFILLED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(actorUserId),
|
||
actorName: ownerDisplayName,
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* V2: Owner acknowledges a description-only expert resend (no extra documents or part photos requested).
|
||
*/
|
||
async acknowledgeExpertResendInstructionsV2(
|
||
claimRequestId: string,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<{
|
||
claimRequestId: string;
|
||
status: string;
|
||
claimStatus: string;
|
||
currentStep: string;
|
||
message: string;
|
||
}> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner can complete this step",
|
||
);
|
||
if (
|
||
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
|
||
) {
|
||
throw new BadRequestException(
|
||
`No expert resend step is active. Current step: ${claimCase.workflow?.currentStep ?? "none"}`,
|
||
);
|
||
}
|
||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for resend action. Current status: ${claimCase.status}`,
|
||
);
|
||
}
|
||
const r = claimCase.evaluation?.damageExpertResend;
|
||
if (!r || r.fulfilledAt) {
|
||
throw new BadRequestException(
|
||
"There is no active expert resend request to acknowledge.",
|
||
);
|
||
}
|
||
if (
|
||
normalizeResendDocumentKeys(r.resendDocuments).length > 0 ||
|
||
normalizeResendPartKeys(
|
||
r.resendCarParts,
|
||
claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||
claimCase.damage?.selectedParts,
|
||
).length > 0
|
||
) {
|
||
throw new BadRequestException(
|
||
"Upload all requested documents and part photos before completing the resend step.",
|
||
);
|
||
}
|
||
await this.tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId,
|
||
effectiveUserId,
|
||
claimCase.owner?.fullName || "User",
|
||
);
|
||
const updated = await this.claimCaseDbService.findById(claimRequestId);
|
||
return {
|
||
claimRequestId,
|
||
status: String(updated?.status ?? ""),
|
||
claimStatus: String(updated?.claimStatus ?? ""),
|
||
currentStep: String(updated?.workflow?.currentStep ?? ""),
|
||
message: "Claim returned to the damage expert queue.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2 API: Upload required document
|
||
*/
|
||
async uploadRequiredDocumentV2(
|
||
claimRequestId: string,
|
||
body: UploadRequiredDocumentV2Dto,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
options?: { v3InPersonFlow?: boolean },
|
||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner can upload documents",
|
||
);
|
||
|
||
const step = claimCase.workflow?.currentStep;
|
||
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||
const isCapturePhaseDocUpload =
|
||
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
|
||
isCapturePhaseDamagedPartyDocKey(body.documentKey);
|
||
|
||
if (!isResendUpload) {
|
||
if (
|
||
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
|
||
!isCapturePhaseDocUpload
|
||
) {
|
||
throw new BadRequestException(
|
||
`During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}), and only after all damaged parts and car angles are captured.`,
|
||
);
|
||
}
|
||
if (isCapturePhaseDocUpload) {
|
||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||
if (!captureProgress.partsComplete) {
|
||
throw new BadRequestException(
|
||
"Upload photos for all selected damaged parts before uploading chassis, engine, or metal plate photos.",
|
||
);
|
||
}
|
||
if (!captureProgress.anglesComplete) {
|
||
throw new BadRequestException(
|
||
"Capture all four car angles (front, back, left, right) before uploading chassis, engine, or metal plate photos.",
|
||
);
|
||
}
|
||
}
|
||
const allowedInitialUploadStep =
|
||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
|
||
isCapturePhaseDocUpload;
|
||
if (!allowedInitialUploadStep) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
if (options?.v3InPersonFlow) {
|
||
if (
|
||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
||
isCapturePhaseDamagedPartyDocKey(body.documentKey)
|
||
) {
|
||
const capturePartDone = (
|
||
claimCase.workflow?.completedSteps ?? []
|
||
).includes(ClaimWorkflowStep.CAPTURE_PART_DAMAGES);
|
||
if (!capturePartDone) {
|
||
throw new BadRequestException(
|
||
`Complete outer/other part selection and capture-part (damaged parts + car angles) before uploading chassis, engine, and metal plate photos (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}).`,
|
||
);
|
||
}
|
||
throw new BadRequestException(
|
||
`Chassis, engine, and metal plate photos must be uploaded during ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} after parts and angles are captured.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isResendUpload) {
|
||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`,
|
||
);
|
||
}
|
||
if (!documentKeyAllowedForExpertResend(claimCase, body.documentKey)) {
|
||
throw new BadRequestException(
|
||
`Document "${body.documentKey}" was not requested by the damage expert for this resend.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// CAR_BODY claims only allow 7 document types (no guilty_*)
|
||
const blameForUpload = claimCase.blameRequestId
|
||
? await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId.toString(),
|
||
)
|
||
: null;
|
||
const isCarBodyUpload =
|
||
blameForUpload?.type === BlameRequestType.CAR_BODY;
|
||
const guiltyKeys = [
|
||
"guilty_driving_license_front",
|
||
"guilty_driving_license_back",
|
||
"guilty_car_card_front",
|
||
"guilty_car_card_back",
|
||
"guilty_metal_plate",
|
||
];
|
||
if (isCarBodyUpload && guiltyKeys.includes(body.documentKey)) {
|
||
throw new BadRequestException(
|
||
`Document type ${body.documentKey} is not required for CAR_BODY claims`,
|
||
);
|
||
}
|
||
|
||
// Initial flow: each document once. Expert resend and v3 in-person: allow replacement.
|
||
if (!isResendUpload && !options?.v3InPersonFlow) {
|
||
const existingDoc =
|
||
(claimCase.requiredDocuments as any)?.get?.(body.documentKey) ??
|
||
(claimCase.requiredDocuments as any)?.[body.documentKey];
|
||
if (existingDoc?.uploaded) {
|
||
throw new ConflictException(
|
||
`Document ${body.documentKey} has already been uploaded`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const fileUrl = buildFileLink(file.path);
|
||
|
||
const docRef = await this.claimRequiredDocumentDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: new Types.ObjectId(claimRequestId),
|
||
documentType: body.documentKey,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
// Base history entry for this upload
|
||
const uploadHistoryEntry = {
|
||
type: "DOCUMENT_UPLOADED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
documentKey: body.documentKey,
|
||
fileName: file.filename,
|
||
fileId: docRef._id.toString(),
|
||
},
|
||
};
|
||
|
||
// Base $set — always applied
|
||
const $set: Record<string, unknown> = {
|
||
[`requiredDocuments.${body.documentKey}`]: {
|
||
fileId: docRef._id,
|
||
filePath: file.path,
|
||
fileName: file.filename,
|
||
uploaded: true,
|
||
uploadedAt: new Date(),
|
||
},
|
||
};
|
||
|
||
// History entries to push — always at least the upload entry
|
||
const historyEntries: unknown[] = [uploadHistoryEntry];
|
||
|
||
// completedSteps entries to push
|
||
const completedStepsEntries: string[] = [];
|
||
|
||
let allDocumentsUploaded = false;
|
||
let remaining = 0;
|
||
let captureStepCompletedOnThisUpload = false;
|
||
|
||
if (!isResendUpload) {
|
||
if (isCapturePhaseDocUpload) {
|
||
const afterThis = (k: string) =>
|
||
k === body.documentKey ||
|
||
this.isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
||
(k) => !afterThis(k),
|
||
).length;
|
||
|
||
if (remaining === 0) {
|
||
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
|
||
assumeCapturePhaseDocKey: body.documentKey,
|
||
});
|
||
|
||
if (
|
||
progressAfterDoc.partsComplete &&
|
||
progressAfterDoc.anglesComplete &&
|
||
progressAfterDoc.capturePhaseDocsComplete
|
||
) {
|
||
captureStepCompletedOnThisUpload = true;
|
||
$set["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
|
||
$set["workflow.currentStep"] =
|
||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
||
$set["workflow.nextStep"] = options?.v3InPersonFlow
|
||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||
: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||
|
||
completedStepsEntries.push(
|
||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
);
|
||
historyEntries.push({
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
description:
|
||
"Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.",
|
||
},
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
const v3InitialDocsComplete =
|
||
!!options?.v3InPersonFlow &&
|
||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
||
this.allV3PreCaptureDocumentsComplete(
|
||
claimCase,
|
||
isCarBodyUpload,
|
||
body.documentKey,
|
||
);
|
||
|
||
allDocumentsUploaded = options?.v3InPersonFlow
|
||
? v3InitialDocsComplete
|
||
: this.allV2OwnerDocumentsComplete(
|
||
claimCase,
|
||
isCarBodyUpload,
|
||
body.documentKey,
|
||
);
|
||
remaining = options?.v3InPersonFlow
|
||
? this.countRemainingV3PreCaptureDocuments(
|
||
claimCase,
|
||
isCarBodyUpload,
|
||
body.documentKey,
|
||
)
|
||
: this.countRemainingV2OwnerDocuments(
|
||
claimCase,
|
||
isCarBodyUpload,
|
||
body.documentKey,
|
||
);
|
||
|
||
if (allDocumentsUploaded) {
|
||
if (options?.v3InPersonFlow) {
|
||
// V4 split flow: mark the claim as waiting for the FileReviewer.
|
||
// The FileReviewer calls accident-fields then select-outer-parts, at
|
||
// which point advanceV3ClaimToOuterPartsIfReady() moves the claim to
|
||
// SELECTING_OUTER_PARTS / SELECT_OUTER_PARTS.
|
||
$set["status"] = ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER;
|
||
$set["workflow.nextStep"] = ClaimWorkflowStep.SELECT_OUTER_PARTS;
|
||
|
||
completedStepsEntries.push(
|
||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
);
|
||
historyEntries.push({
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
description:
|
||
"FileMaker documents complete. File sealed — awaiting FileReviewer.",
|
||
v3InPersonFlow: true,
|
||
},
|
||
});
|
||
} else {
|
||
$set["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||
$set["claimStatus"] = ClaimStatus.PENDING;
|
||
$set["workflow.currentStep"] =
|
||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||
$set["workflow.nextStep"] =
|
||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||
|
||
completedStepsEntries.push(
|
||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
);
|
||
historyEntries.push(
|
||
{
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
description: "All required documents uploaded",
|
||
},
|
||
},
|
||
{
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
description:
|
||
"User submission complete. Claim ready for damage expert review.",
|
||
},
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build the final MongoDB update — all operators explicit and clean
|
||
const updatePayload: Record<string, unknown> = { $set };
|
||
|
||
const $push: Record<string, unknown> = {
|
||
history:
|
||
historyEntries.length === 1
|
||
? historyEntries[0]
|
||
: { $each: historyEntries },
|
||
};
|
||
if (completedStepsEntries.length === 1) {
|
||
$push["workflow.completedSteps"] = completedStepsEntries[0];
|
||
} else if (completedStepsEntries.length > 1) {
|
||
$push["workflow.completedSteps"] = { $each: completedStepsEntries };
|
||
}
|
||
updatePayload.$push = $push;
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
updatePayload,
|
||
);
|
||
|
||
if (isResendUpload) {
|
||
await this.tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId,
|
||
currentUserId,
|
||
claimCase.owner?.fullName || "User",
|
||
);
|
||
const refreshed =
|
||
await this.claimCaseDbService.findById(claimRequestId);
|
||
const expertResendComplete =
|
||
!!refreshed?.evaluation?.damageExpertResend?.fulfilledAt;
|
||
const fanavaranAttachment = await this.autoSubmitFanavaranAttachment(
|
||
claimRequestId,
|
||
{
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
source: `document:${body.documentKey}:resend`,
|
||
},
|
||
);
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
documentKey: body.documentKey,
|
||
fileUrl,
|
||
allDocumentsUploaded: false,
|
||
expertResendComplete,
|
||
currentStep:
|
||
refreshed?.workflow?.currentStep ||
|
||
ClaimWorkflowStep.USER_EXPERT_RESEND,
|
||
message: expertResendComplete
|
||
? "Expert resend requirements are complete. Your claim is back in the damage expert queue."
|
||
: "Document uploaded for expert resend.",
|
||
fanavaranAttachment,
|
||
};
|
||
}
|
||
|
||
const message = isCapturePhaseDocUpload
|
||
? captureStepCompletedOnThisUpload
|
||
? "Capture-phase documents and all damage captures are complete. Please proceed to upload the remaining required documents."
|
||
: remaining > 0
|
||
? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate).`
|
||
: capturePhaseSequenceMessage(
|
||
getClaimCaptureProgress(claimCase, {
|
||
assumeCapturePhaseDocKey: body.documentKey,
|
||
}).sequencePhase,
|
||
)
|
||
: allDocumentsUploaded
|
||
? options?.v3InPersonFlow
|
||
? "All FileMaker documents uploaded. File is sealed and ready for FileReviewer pickup."
|
||
: "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
documentKey: body.documentKey,
|
||
fileUrl,
|
||
allDocumentsUploaded,
|
||
currentStep: isCapturePhaseDocUpload
|
||
? captureStepCompletedOnThisUpload
|
||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
||
: allDocumentsUploaded
|
||
? options?.v3InPersonFlow
|
||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||
: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
message,
|
||
fanavaranAttachment: await this.autoSubmitFanavaranAttachment(
|
||
claimRequestId,
|
||
{
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
source: `document:${body.documentKey}`,
|
||
},
|
||
),
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error("uploadRequiredDocumentV2 failed", error);
|
||
throw new InternalServerErrorException("Failed to upload document");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Capture car angle or damaged part
|
||
*/
|
||
async capturePartV2(
|
||
claimRequestId: string,
|
||
body: CapturePartV2Dto,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<CapturePartV2ResponseDto> {
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner can capture parts",
|
||
);
|
||
|
||
const capStep = claimCase.workflow?.currentStep;
|
||
const isResendCapture = capStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||
if (
|
||
!isResendCapture &&
|
||
capStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
||
) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
if (isResendCapture) {
|
||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`,
|
||
);
|
||
}
|
||
if (body.captureType !== "part") {
|
||
throw new BadRequestException(
|
||
"During expert resend only damaged-part photos can be uploaded (not car angles).",
|
||
);
|
||
}
|
||
if (!partKeyAllowedForExpertResend(claimCase, body.captureKey)) {
|
||
throw new BadRequestException(
|
||
`Part "${body.captureKey}" was not requested by the damage expert for this resend.`,
|
||
);
|
||
}
|
||
if (canonicalizeClaimCarAngleKey(body.captureKey)) {
|
||
throw new BadRequestException(
|
||
"During expert resend only damaged-part photos are allowed, not car angles (front/back/left/right).",
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!isResendCapture) {
|
||
const captureProgressBefore = getClaimCaptureProgress(claimCase);
|
||
const willBeAngle =
|
||
body.captureType === "angle" ||
|
||
(body.captureType === "part" &&
|
||
canonicalizeClaimCarAngleKey(body.captureKey) !== null);
|
||
if (willBeAngle && !captureProgressBefore.partsComplete) {
|
||
throw new BadRequestException(
|
||
"Upload photos for all selected damaged parts before capturing car angles.",
|
||
);
|
||
}
|
||
}
|
||
|
||
const angleCanon = canonicalizeClaimCarAngleKey(body.captureKey);
|
||
if (body.captureType === "angle" && !angleCanon) {
|
||
throw new BadRequestException(
|
||
`Invalid angle captureKey "${body.captureKey}". Use one of: front, back, left, right.`,
|
||
);
|
||
}
|
||
const treatAsAngle =
|
||
!isResendCapture &&
|
||
(body.captureType === "angle" ||
|
||
(body.captureType === "part" && angleCanon !== null));
|
||
|
||
const fileUrl = buildFileLink(file.path);
|
||
const captureData = {
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
url: fileUrl,
|
||
capturedAt: new Date(),
|
||
};
|
||
|
||
const updateData: any = {
|
||
$push: {
|
||
history: {
|
||
type: treatAsAngle ? "ANGLE_CAPTURED" : "PART_CAPTURED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
captureType: body.captureType,
|
||
captureKey: body.captureKey,
|
||
...(treatAsAngle && angleCanon
|
||
? { resolvedCarAngleKey: angleCanon }
|
||
: {}),
|
||
fileName: file.filename,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const carType = claimCase.vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const selectedOuterLegacy = (claimCase.damage as any)?.selectedOuterParts;
|
||
const selectedBeforeNorm = normalizeDamageSelectedParts(
|
||
claimCase.damage?.selectedParts,
|
||
carType,
|
||
selectedOuterLegacy,
|
||
);
|
||
let nextSelected = [...selectedBeforeNorm];
|
||
let nextMedia = coerceDamagedPartsMediaToArray(
|
||
claimCase.media?.damagedParts,
|
||
nextSelected,
|
||
);
|
||
|
||
if (treatAsAngle && angleCanon) {
|
||
updateData[`media.carAngles.${angleCanon}`] = captureData;
|
||
if (!Array.isArray(claimCase.media?.damagedParts)) {
|
||
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
|
||
angleCanon,
|
||
body.captureKey,
|
||
claimCase.damage?.selectedParts,
|
||
carType,
|
||
selectedOuterLegacy,
|
||
);
|
||
if (unsetFieldKeys.length) {
|
||
updateData.$unset = {};
|
||
for (const k of unsetFieldKeys) {
|
||
updateData.$unset[`media.damagedParts.${k}`] = "";
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
let idx = resolvePartCaptureIndex(
|
||
body.captureKey,
|
||
nextSelected,
|
||
nextMedia,
|
||
);
|
||
if (isResendCapture && body.captureType === "part" && idx < 0) {
|
||
const added = normalizeDamageSelectedParts(
|
||
[body.captureKey],
|
||
carType,
|
||
undefined,
|
||
);
|
||
const row = added[0] || {
|
||
id: null,
|
||
name: body.captureKey,
|
||
side: "",
|
||
label_fa: body.captureKey,
|
||
catalogKey: body.captureKey,
|
||
};
|
||
nextSelected.push(row);
|
||
idx = nextSelected.length - 1;
|
||
nextMedia.push({});
|
||
}
|
||
if (idx < 0) {
|
||
throw new BadRequestException(
|
||
`Unknown damaged-part captureKey "${body.captureKey}". Use catalog id, 0-based index, or catalog key.`,
|
||
);
|
||
}
|
||
while (nextMedia.length <= idx) {
|
||
nextMedia.push({});
|
||
}
|
||
const base = nextSelected[idx] || {
|
||
id: null,
|
||
name: String(body.captureKey),
|
||
side: "",
|
||
label_fa: String(body.captureKey),
|
||
};
|
||
nextMedia[idx] = {
|
||
...(base.id != null ? { id: base.id } : {}),
|
||
name: base.name,
|
||
side: base.side,
|
||
label_fa: base.label_fa,
|
||
...(base.catalogKey ? { catalogKey: base.catalogKey } : {}),
|
||
...nextMedia[idx],
|
||
...captureData,
|
||
};
|
||
updateData["media.damagedParts"] = nextMedia;
|
||
if (
|
||
isResendCapture &&
|
||
nextSelected.length !== selectedBeforeNorm.length
|
||
) {
|
||
updateData["damage.selectedParts"] = nextSelected;
|
||
}
|
||
}
|
||
|
||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
updateData,
|
||
);
|
||
|
||
if (isResendCapture) {
|
||
await this.tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId,
|
||
currentUserId,
|
||
claimCase.owner?.fullName || "User",
|
||
);
|
||
const refreshed =
|
||
await this.claimCaseDbService.findById(claimRequestId);
|
||
const expertResendComplete =
|
||
!!refreshed?.evaluation?.damageExpertResend?.fulfilledAt;
|
||
const fanavaranAttachment = await this.autoSubmitFanavaranAttachment(
|
||
claimRequestId,
|
||
{
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
source: `capture:${body.captureType}:${body.captureKey}:resend`,
|
||
},
|
||
);
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
captureType: body.captureType,
|
||
captureKey: body.captureKey,
|
||
fileUrl,
|
||
allCapturesComplete: expertResendComplete,
|
||
expertResendComplete,
|
||
currentStep:
|
||
refreshed?.workflow?.currentStep ||
|
||
ClaimWorkflowStep.USER_EXPERT_RESEND,
|
||
message: expertResendComplete
|
||
? "Expert resend requirements are complete. Your claim is back in the damage expert queue."
|
||
: "Part photo uploaded for expert resend.",
|
||
fanavaranAttachment,
|
||
};
|
||
}
|
||
|
||
const captureProgress = getClaimCaptureProgress(updatedClaim);
|
||
const allCapturesComplete = isClaimCaptureStepComplete(updatedClaim);
|
||
|
||
if (allCapturesComplete) {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||
"workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
"workflow.nextStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
history: {
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
description:
|
||
"Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
const partsRemaining = Math.max(
|
||
0,
|
||
captureProgress.partsTotal - captureProgress.partsCaptured,
|
||
);
|
||
const anglesRemaining = Math.max(
|
||
0,
|
||
captureProgress.anglesTotal - captureProgress.anglesCaptured,
|
||
);
|
||
const remaining =
|
||
partsRemaining +
|
||
anglesRemaining +
|
||
captureProgress.capturePhaseDocsRemaining;
|
||
const message = allCapturesComplete
|
||
? "All captures complete. Please proceed to upload the remaining required documents."
|
||
: `${body.captureType === "angle" ? "Angle" : "Part"} captured successfully. ${capturePhaseSequenceMessage(captureProgress.sequencePhase)} (${remaining} item(s) left in this step).`;
|
||
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
captureType: body.captureType,
|
||
captureKey: body.captureKey,
|
||
fileUrl,
|
||
allCapturesComplete,
|
||
currentStep: allCapturesComplete
|
||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
message,
|
||
fanavaranAttachment: await this.autoSubmitFanavaranAttachment(
|
||
claimRequestId,
|
||
{
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
source: `capture:${body.captureType}:${body.captureKey}`,
|
||
},
|
||
),
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error("capturePartV2 failed", error);
|
||
throw new InternalServerErrorException("Failed to capture part");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Upload car walk-around video (same storage as v1: claim-video-capture + ClaimCase.media.videoCaptureId).
|
||
*/
|
||
async setVideoCaptureV2(
|
||
claimRequestId: string,
|
||
fileDetail: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<VideoCaptureV2ResponseDto> {
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner can upload car capture video",
|
||
);
|
||
|
||
if (!fileDetail) {
|
||
throw new BadRequestException("Video file is required.");
|
||
}
|
||
|
||
if (
|
||
claimCase.workflow?.currentStep !==
|
||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||
) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
|
||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||
if (!captureProgress.partsComplete) {
|
||
throw new BadRequestException(
|
||
"Upload photos for all selected damaged parts before uploading the walk-around video.",
|
||
);
|
||
}
|
||
if (!captureProgress.anglesComplete) {
|
||
throw new BadRequestException(
|
||
"Capture all four car angles (front, back, left, right) before uploading the walk-around video.",
|
||
);
|
||
}
|
||
|
||
if (claimCase.media?.videoCaptureId) {
|
||
throw new ConflictException(
|
||
"A video has already been uploaded for this claim.",
|
||
);
|
||
}
|
||
|
||
const videoDoc = await this.createVideoCapture(
|
||
fileDetail,
|
||
claimRequestId,
|
||
);
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: { "media.videoCaptureId": videoDoc._id },
|
||
$push: {
|
||
history: {
|
||
type: "VIDEO_CAPTURE_UPLOADED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
videoId: videoDoc._id.toString(),
|
||
fileName: fileDetail.filename,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
videoId: videoDoc._id.toString(),
|
||
message: "Video capture uploaded successfully.",
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error("setVideoCaptureV2 failed", error);
|
||
throw new InternalServerErrorException("Failed to upload video capture");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether an expert resend request exists (user is expected to respond).
|
||
*/
|
||
private claimCaseHasExpertResend(claimCase: any): boolean {
|
||
const r = claimCase?.evaluation?.damageExpertResend;
|
||
if (!r) return false;
|
||
if (
|
||
typeof r.resendDescription === "string" &&
|
||
r.resendDescription.trim() !== ""
|
||
) {
|
||
return true;
|
||
}
|
||
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0)
|
||
return true;
|
||
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0)
|
||
return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* V2 API: User objection (ClaimCase + evaluation.objection payload).
|
||
*
|
||
* Priced-flow: insurer-review post-expert statuses (`INSURER_REVIEW_AWAITING_OWNER_SIGN`, `INSURER_REVIEW_MIXED_FACTORS_PENDING`, or legacy `WAITING_FOR_INSURER_APPROVAL`) @ `INSURER_REVIEW` — either NEEDS_REVISION (mixed-priced gate before factor upload)
|
||
* or APPROVED (final totals after pricing / factor validation); never while uploading factors or while expert validates.
|
||
* Legacy: objection during unpaid expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).
|
||
* Post: resets owner signatures when applicable and returns claim to WAITING_FOR_DAMAGE_EXPERT.
|
||
*/
|
||
async handleUserObjectionV2(
|
||
claimRequestId: string,
|
||
body: UserObjectionV2Dto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
) {
|
||
try {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner can submit an objection",
|
||
);
|
||
|
||
if (claimCase.evaluation?.objection?.submittedAt) {
|
||
throw new ConflictException(
|
||
"An objection has already been submitted for this claim.",
|
||
);
|
||
}
|
||
|
||
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
|
||
const pendingResendGate =
|
||
hasExpertResend &&
|
||
claimCase.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND &&
|
||
claimCase.workflow?.currentStep ===
|
||
ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||
|
||
if (claimCase.evaluation?.damageExpertReplyFinal) {
|
||
throw new ConflictException(
|
||
"The expert already submitted the final reply after objection; submit accept/reject via the owner signer.",
|
||
);
|
||
}
|
||
|
||
const activeExpert = getActiveV2ExpertReply(
|
||
claimCase as unknown as { evaluation?: Record<string, unknown> },
|
||
);
|
||
|
||
const partsIn = body.objectionParts ?? [];
|
||
const newPartsIn = body.newParts ?? [];
|
||
if (partsIn.length === 0 && newPartsIn.length === 0) {
|
||
throw new BadRequestException(
|
||
"Provide at least one entry in objectionParts and/or newParts.",
|
||
);
|
||
}
|
||
|
||
let pricingObjectionEligible = false;
|
||
if (
|
||
claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status) &&
|
||
claimCase.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW &&
|
||
!claimCase.evaluation?.ownerInsurerApproval?.signedAt
|
||
) {
|
||
if (objectionDisallowedDueToOutstandingFactorWorkflow(claimCase)) {
|
||
throw new BadRequestException(
|
||
"Objections are paused while factor files are uploading or the expert is validating repair factors.",
|
||
);
|
||
}
|
||
|
||
const shape = classifyV2ExpertPricingParts(activeExpert?.parts ?? []);
|
||
const atMixedPartialGate =
|
||
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
|
||
shape.mixedFactorAndPrice &&
|
||
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
|
||
const atFinalPricingGate =
|
||
claimCase.claimStatus === ClaimStatus.APPROVED;
|
||
pricingObjectionEligible = atMixedPartialGate || atFinalPricingGate;
|
||
}
|
||
|
||
if (!(pendingResendGate || pricingObjectionEligible)) {
|
||
throw new BadRequestException(
|
||
"No open objection window. Use this after a priced insurer-review step without a recorded final approval, " +
|
||
"or while completing an unpaid expert resend.",
|
||
);
|
||
}
|
||
|
||
if (
|
||
!pendingResendGate &&
|
||
pricingObjectionEligible &&
|
||
!activeExpert?.parts?.length
|
||
) {
|
||
throw new BadRequestException(
|
||
"A priced assessment is required before you can object.",
|
||
);
|
||
}
|
||
|
||
const pricedCatalogIds = activeExpert?.parts?.length
|
||
? new Set(
|
||
activeExpert.parts
|
||
.filter((p) => p.factorNeeded !== true)
|
||
.map((p) => parseCatalogPartIdInput(p.partId))
|
||
.filter((id): id is number => id != null),
|
||
)
|
||
: new Set<number>();
|
||
|
||
if (partsIn.length && pricingObjectionEligible) {
|
||
for (const op of partsIn) {
|
||
const catalogId = parseCatalogPartIdInput(op.partId);
|
||
if (catalogId == null || !pricedCatalogIds.has(catalogId)) {
|
||
throw new BadRequestException(
|
||
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const objectionParts = partsIn.map((p) => ({
|
||
partId: parseCatalogPartIdInput(p.partId)!,
|
||
reason: p.reason?.trim() || undefined,
|
||
partPrice:
|
||
p.partPrice != null && String(p.partPrice).trim() !== ""
|
||
? normalizeMoneyAmountString(String(p.partPrice))
|
||
: undefined,
|
||
partSalary:
|
||
p.partSalary != null && String(p.partSalary).trim() !== ""
|
||
? normalizeMoneyAmountString(String(p.partSalary))
|
||
: undefined,
|
||
typeOfDamage:
|
||
p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
|
||
carPartDamage: p.carPartDamage?.trim() || undefined,
|
||
side: p.side?.trim() || undefined,
|
||
}));
|
||
|
||
const newParts = newPartsIn.map((p) => ({
|
||
partId:
|
||
p.partId != null && parseCatalogPartIdInput(p.partId) != null
|
||
? parseCatalogPartIdInput(p.partId)!
|
||
: new Types.ObjectId().toString(),
|
||
partName: p.partName.trim(),
|
||
side: p.side?.trim() || undefined,
|
||
}));
|
||
|
||
const objectionCarType = claimCase.vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const mergedNorm = normalizeDamageSelectedParts(
|
||
claimCase.damage?.selectedParts,
|
||
objectionCarType,
|
||
(claimCase.damage as any)?.selectedOuterParts,
|
||
);
|
||
const mergedKeys = new Set(mergedNorm.map((p) => partLookupKey(p)));
|
||
for (const np of newParts) {
|
||
const name = np.partName?.trim();
|
||
if (!name) continue;
|
||
const side = String(np.side ?? "").trim();
|
||
const row = {
|
||
id: null as number | null,
|
||
name,
|
||
side,
|
||
label_fa: name,
|
||
};
|
||
const k = partLookupKey(row);
|
||
if (!mergedKeys.has(k)) {
|
||
mergedNorm.push(row);
|
||
mergedKeys.add(k);
|
||
}
|
||
}
|
||
|
||
const objectionPayload = {
|
||
objectionParts,
|
||
newParts,
|
||
submittedAt: new Date(),
|
||
};
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
"workflow.locked": false,
|
||
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||
"evaluation.objection": objectionPayload,
|
||
"damage.selectedParts": mergedNorm,
|
||
...(pendingResendGate && {
|
||
"evaluation.damageExpertResend.fulfilledAt": new Date(),
|
||
}),
|
||
},
|
||
$unset: {
|
||
"evaluation.ownerPricedPartsApproval": "",
|
||
"evaluation.ownerInsurerApproval": "",
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "USER_OBJECTION_SUBMITTED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(effectiveUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
objectionPartCount: objectionParts.length,
|
||
newPartCount: newParts.length,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
return objectionPayload;
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error("handleUserObjectionV2 failed", error);
|
||
throw new InternalServerErrorException("Failed to submit objection");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: User satisfaction rating for a completed claim case (same intent as v1 PUT …/request/:id/user-rating).
|
||
*/
|
||
async addUserRatingV2(
|
||
claimRequestId: string,
|
||
ratingDto: UserRatingDto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<UserClaimRating> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner can submit a satisfaction rating.",
|
||
);
|
||
|
||
if (claimCase.status !== ClaimCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
"You can only rate a claim after it has been completed.",
|
||
);
|
||
}
|
||
|
||
if (claimCase.userRating?.createdAt) {
|
||
throw new ConflictException(
|
||
"A rating has already been submitted for this claim.",
|
||
);
|
||
}
|
||
|
||
const score = (n: number) => Number(n);
|
||
for (const [key, label] of [
|
||
[ratingDto.progressSpeed, "progressSpeed"],
|
||
[ratingDto.registrationEase, "registrationEase"],
|
||
[ratingDto.overallEvaluation, "overallEvaluation"],
|
||
] as const) {
|
||
const v = score(key);
|
||
if (Number.isNaN(v) || v < 0 || v > 5) {
|
||
throw new BadRequestException(
|
||
`${label} must be a number between 0 and 5.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const userRating: UserClaimRating = {
|
||
progressSpeed: ratingDto.progressSpeed,
|
||
registrationEase: ratingDto.registrationEase,
|
||
overallEvaluation: ratingDto.overallEvaluation,
|
||
comment: ratingDto.comment,
|
||
createdAt: new Date(),
|
||
};
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: { userRating },
|
||
$push: {
|
||
history: {
|
||
type: "USER_RATING_SUBMITTED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(effectiveUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {},
|
||
},
|
||
},
|
||
});
|
||
|
||
return userRating;
|
||
}
|
||
|
||
/**
|
||
* V2: Claim owner signature on expert pricing.
|
||
*
|
||
* Phase 1 (mixed priced + factor-needed lines): `INSURER_REVIEW_MIXED_FACTORS_PENDING` (or legacy `WAITING_FOR_INSURER_APPROVAL`), NEEDS_REVISION, INSURER_REVIEW,
|
||
* owner has not yet accepted priced lines — agreeing records `evaluation.ownerPricedPartsApproval` and moves the
|
||
* workflow to OWNER_UPLOAD_FACTOR_DOCUMENTS. Rejecting rejects the whole case.
|
||
*
|
||
* Phase 2 (final): INSURER_REVIEW_AWAITING_OWNER_SIGN (or legacy WAITING_FOR_INSURER_APPROVAL), APPROVED, INSURER_REVIEW — full accept/reject completes the case.
|
||
*/
|
||
async submitOwnerInsurerApprovalSignV2(
|
||
claimRequestId: string,
|
||
agree: boolean,
|
||
branchId: string,
|
||
signFile: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string; fullName?: string },
|
||
): Promise<{
|
||
message: string;
|
||
claimRequestId: string;
|
||
status: ClaimCaseStatus;
|
||
claimStatus: ClaimStatus;
|
||
currentStep?: ClaimWorkflowStep;
|
||
accepted: boolean;
|
||
phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL";
|
||
fanavaran?: FanavaranAutoSubmitResult;
|
||
}> {
|
||
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||
throw new BadRequestException("Invalid claim request id");
|
||
}
|
||
if (!signFile) {
|
||
throw new BadRequestException("A signature file is required");
|
||
}
|
||
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
await this.assertEffectiveUserIsDamagedPartyOnClaim(
|
||
claimCase,
|
||
effectiveUserId,
|
||
actor as { username?: string },
|
||
"Only the claim owner (damaged party) can sign at this step.",
|
||
);
|
||
|
||
if (!claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status)) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for your insurer-approval signature. Current status: ${String(claimCase.status)}`,
|
||
);
|
||
}
|
||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) {
|
||
throw new BadRequestException(
|
||
`Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`,
|
||
);
|
||
}
|
||
|
||
const active =
|
||
claimCase.evaluation?.damageExpertReplyFinal ||
|
||
claimCase.evaluation?.damageExpertReply;
|
||
if (!active?.submittedAt) {
|
||
throw new BadRequestException(
|
||
"No expert reply is available to sign off on yet.",
|
||
);
|
||
}
|
||
|
||
const shape = classifyV2ExpertPricingParts(active.parts ?? []);
|
||
const pricedBranchReply = {
|
||
parts: (active.parts ?? []).filter(
|
||
(p: { factorNeeded?: boolean }) => !p.factorNeeded,
|
||
),
|
||
};
|
||
|
||
const isMixedPartialGate =
|
||
shape.mixedFactorAndPrice &&
|
||
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
|
||
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
|
||
|
||
const isFinalApprovalGate =
|
||
claimCase.claimStatus === ClaimStatus.APPROVED &&
|
||
!claimCase.evaluation?.ownerInsurerApproval?.signedAt;
|
||
|
||
if (!isMixedPartialGate && !isFinalApprovalGate) {
|
||
throw new BadRequestException(
|
||
"No owner signature action is available at this state. " +
|
||
"If repair factors are pending upload, complete the priced-line signature first (when claimStatus is NEEDS_REVISION). " +
|
||
"If you already completed the final approval signature, refresh the claim.",
|
||
);
|
||
}
|
||
|
||
if (
|
||
isMixedPartialGate &&
|
||
claimCase.evaluation?.ownerInsurerApproval?.signedAt
|
||
) {
|
||
throw new ConflictException(
|
||
"Claim already has a final approval record; refresh the claim state.",
|
||
);
|
||
}
|
||
if (
|
||
isFinalApprovalGate &&
|
||
claimCase.evaluation?.ownerInsurerApproval?.signedAt
|
||
) {
|
||
throw new ConflictException(
|
||
"You have already submitted a signature for this claim.",
|
||
);
|
||
}
|
||
|
||
const rawBranchId = (branchId ?? "").trim();
|
||
if (!rawBranchId || !Types.ObjectId.isValid(rawBranchId)) {
|
||
throw new BadRequestException(
|
||
"branchId is required and must be a valid MongoDB ObjectId.",
|
||
);
|
||
}
|
||
const branchDoc = await this.branchDbService.findById(rawBranchId);
|
||
if (!branchDoc) {
|
||
throw new NotFoundException(`Branch with ID ${rawBranchId} not found.`);
|
||
}
|
||
const ownerClientId = claimCase.owner?.clientId;
|
||
if (
|
||
!ownerClientId ||
|
||
String(branchDoc.clientKey) !== String(ownerClientId)
|
||
) {
|
||
throw new ForbiddenException(
|
||
"This branch does not belong to the insurer for this claim.",
|
||
);
|
||
}
|
||
const branchIdsOnPricing = isMixedPartialGate
|
||
? this.collectBranchIdsFromClaimExpertReply(pricedBranchReply)
|
||
: this.collectBranchIdsFromClaimExpertReply(
|
||
active as { parts?: unknown[] },
|
||
);
|
||
if (
|
||
branchIdsOnPricing.size > 0 &&
|
||
!branchIdsOnPricing.has(String(rawBranchId))
|
||
) {
|
||
throw new BadRequestException(
|
||
"branchId must match a branch used in the expert pricing for this claim.",
|
||
);
|
||
}
|
||
|
||
const signDoc = await this.claimSignDbService.create({
|
||
fileName: signFile.filename,
|
||
userId: effectiveUserId,
|
||
path: signFile.path,
|
||
requestId: new Types.ObjectId(claimRequestId),
|
||
} as any);
|
||
const signId = new Types.ObjectId(String((signDoc as any)._id));
|
||
const signedAt = new Date();
|
||
const scopedApprovalPayload = {
|
||
agree,
|
||
branchId: new Types.ObjectId(rawBranchId),
|
||
signDetailId: signId,
|
||
signedAt,
|
||
};
|
||
|
||
const historyActor = {
|
||
actorId: new Types.ObjectId(effectiveUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
};
|
||
|
||
/** Mixed reply: priced lines acceptance before factor uploads */
|
||
if (isMixedPartialGate) {
|
||
if (!agree) {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.REJECTED,
|
||
claimStatus: ClaimStatus.REJECTED,
|
||
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
|
||
"workflow.nextStep": null,
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS",
|
||
actor: historyActor,
|
||
timestamp: signedAt,
|
||
metadata: { branchId: rawBranchId },
|
||
},
|
||
},
|
||
});
|
||
return {
|
||
message:
|
||
"Your rejection has been recorded. This claim is marked rejected and will not proceed.",
|
||
claimRequestId,
|
||
status: ClaimCaseStatus.REJECTED,
|
||
claimStatus: ClaimStatus.REJECTED,
|
||
currentStep: claimCase.workflow?.currentStep,
|
||
accepted: false,
|
||
phase: "PRICED_PARTS_FOR_FACTORS",
|
||
};
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
|
||
"workflow.currentStep":
|
||
ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
|
||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD",
|
||
actor: historyActor,
|
||
timestamp: signedAt,
|
||
metadata: { branchId: rawBranchId },
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
message:
|
||
"Priced repair lines accepted. Upload repair factor files for each factor-needed part; the claim will return to the damage expert for cost validation.",
|
||
claimRequestId,
|
||
status: claimCase.status,
|
||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||
currentStep: ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
|
||
accepted: true,
|
||
phase: "PRICED_PARTS_FOR_FACTORS",
|
||
};
|
||
}
|
||
|
||
/** Final accept / reject (no outstanding factor gate at this step) */
|
||
if (!agree) {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.REJECTED,
|
||
claimStatus: ClaimStatus.REJECTED,
|
||
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
|
||
"workflow.nextStep": null,
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING",
|
||
actor: historyActor,
|
||
timestamp: signedAt,
|
||
metadata: { branchId: rawBranchId },
|
||
},
|
||
},
|
||
});
|
||
return {
|
||
message:
|
||
"Your rejection has been recorded. This claim is marked rejected and will not proceed.",
|
||
claimRequestId,
|
||
status: ClaimCaseStatus.REJECTED,
|
||
claimStatus: ClaimStatus.REJECTED,
|
||
currentStep: claimCase.workflow?.currentStep,
|
||
accepted: false,
|
||
phase: "FINAL_APPROVAL",
|
||
};
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.COMPLETED,
|
||
claimStatus: ClaimStatus.APPROVED,
|
||
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
|
||
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
},
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
|
||
history: {
|
||
type: "OWNER_SIGNED_INSURER_APPROVAL",
|
||
actor: historyActor,
|
||
timestamp: signedAt,
|
||
metadata: { branchId: rawBranchId },
|
||
},
|
||
},
|
||
});
|
||
|
||
const fanavaran =
|
||
await this.autoSubmitToFanavaranV2OnClaimCompleted(claimRequestId);
|
||
|
||
let message = "Your signature has been recorded. The claim is completed.";
|
||
if (fanavaran.submitted) {
|
||
message += " The claim was sent to Fanavaran successfully.";
|
||
} else if (fanavaran.warning) {
|
||
message += ` ${fanavaran.warning}`;
|
||
}
|
||
|
||
return {
|
||
message,
|
||
claimRequestId,
|
||
status: ClaimCaseStatus.COMPLETED,
|
||
claimStatus: ClaimStatus.APPROVED,
|
||
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
accepted: true,
|
||
phase: "FINAL_APPROVAL",
|
||
fanavaran,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
|
||
*/
|
||
async getMyClaimsV2(
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
query: ListQueryV2Dto = {},
|
||
): Promise<GetMyClaimsV2ResponseDto> {
|
||
try {
|
||
let claims: any[];
|
||
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
||
const expertOid = new Types.ObjectId(currentUserId);
|
||
const expertBlameIds = await this.blameRequestDbService
|
||
.find(
|
||
{
|
||
expertInitiated: true,
|
||
initiatedByFieldExpertId: expertOid,
|
||
},
|
||
{ select: "_id", lean: true },
|
||
)
|
||
.then((docs) => docs.map((d) => (d as any)._id));
|
||
const claimOr: Record<string, unknown>[] = [
|
||
{ initiatedByFieldExpertId: expertOid },
|
||
];
|
||
if (expertBlameIds.length > 0) {
|
||
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
|
||
}
|
||
claims = await this.claimCaseDbService.find(
|
||
{ $or: claimOr },
|
||
{ lean: true },
|
||
);
|
||
} else if (actor?.role === RoleEnum.REGISTRAR) {
|
||
const registrarBlameIds = await this.blameRequestDbService
|
||
.find(
|
||
{
|
||
registrarInitiated: true,
|
||
creationMethod: "IN_PERSON",
|
||
initiatedByRegistrarId: new Types.ObjectId(currentUserId),
|
||
},
|
||
{ select: "_id", lean: true },
|
||
)
|
||
.then((docs) => docs.map((d) => (d as any)._id));
|
||
claims = await this.claimCaseDbService.find(
|
||
{
|
||
$or: [
|
||
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
||
...(registrarBlameIds.length
|
||
? [{ blameRequestId: { $in: registrarBlameIds } }]
|
||
: []),
|
||
],
|
||
},
|
||
{ lean: true },
|
||
);
|
||
} else {
|
||
const linkedUserIds = await resolveLinkedUserIdStrings(
|
||
this.userDbService,
|
||
{
|
||
sub: currentUserId,
|
||
username: (actor as { username?: string })?.username,
|
||
},
|
||
);
|
||
const actorCtx = {
|
||
sub: currentUserId,
|
||
username: (actor as { username?: string })?.username,
|
||
};
|
||
const partyBlameOr = buildBlamePartyAccessOrConditions(
|
||
actorCtx,
|
||
linkedUserIds,
|
||
);
|
||
|
||
const idVariants = collectUserIdVariants(...linkedUserIds);
|
||
|
||
const ownerOr = buildOwnerUserIdAccessFilter(idVariants);
|
||
|
||
// damagedPartyUserId filter — direct match without blame lookup
|
||
const damagedOr =
|
||
idVariants.length === 1
|
||
? { damagedPartyUserId: idVariants[0] }
|
||
: idVariants.length > 1
|
||
? { damagedPartyUserId: { $in: idVariants } }
|
||
: null;
|
||
|
||
// Build snapshot-party query: claim.snapshot.parties.person.userId
|
||
// is the most direct and reliable path — it's copied from the blame
|
||
// at claim-creation time and always has the correct damaged-party userId.
|
||
const snapshotPartyFilter =
|
||
idVariants.length === 1
|
||
? { "snapshot.parties.person.userId": idVariants[0] }
|
||
: idVariants.length > 1
|
||
? { "snapshot.parties.person.userId": { $in: idVariants } }
|
||
: null;
|
||
|
||
const [
|
||
ownerClaims,
|
||
damagedDirectClaims,
|
||
snapshotClaims,
|
||
candidateBlames,
|
||
] = await Promise.all([
|
||
ownerOr
|
||
? this.claimCaseDbService.find(ownerOr as any, { lean: true })
|
||
: Promise.resolve([]),
|
||
damagedOr
|
||
? this.claimCaseDbService.find(damagedOr as any, { lean: true })
|
||
: Promise.resolve([]),
|
||
snapshotPartyFilter
|
||
? this.claimCaseDbService.find(snapshotPartyFilter as any, {
|
||
lean: true,
|
||
})
|
||
: Promise.resolve([]),
|
||
partyBlameOr.length
|
||
? this.blameRequestDbService.find(
|
||
partyBlameOr.length === 1
|
||
? (partyBlameOr[0] as any)
|
||
: ({ $or: partyBlameOr } as any),
|
||
{
|
||
lean: true,
|
||
select: "type parties expert.decision blameStatus",
|
||
},
|
||
)
|
||
: Promise.resolve([]),
|
||
]);
|
||
|
||
// Any blame where the user is a party (not just damaged) surfaces
|
||
// the linked claim. Mutation guards protect against acting as the
|
||
// wrong party; viewing is intentionally open to all parties.
|
||
const partyBlameIds = (candidateBlames as any[]).map((b) => b._id);
|
||
|
||
const damagedClaims =
|
||
partyBlameIds.length > 0
|
||
? await this.claimCaseDbService.find(
|
||
{ blameRequestId: { $in: partyBlameIds } },
|
||
{ lean: true },
|
||
)
|
||
: [];
|
||
|
||
// Merge: owner claims + blame-resolved damaged claims + snapshot-party claims
|
||
// snapshotClaims may include guilty-owner claims too — filter them out here.
|
||
const snapshotDamagedClaims = (snapshotClaims as any[]).filter((c) => {
|
||
if (!c?.owner?.userId) return true; // unknown owner: allow
|
||
const ownerStr = String(c.owner.userId);
|
||
// Exclude if the user IS the owner (already in ownerClaims)
|
||
return !linkedUserIds.some((id) => id === ownerStr);
|
||
});
|
||
|
||
const byId = new Map<string, any>();
|
||
for (const c of [
|
||
...(ownerClaims as any[]),
|
||
...(damagedDirectClaims as any[]),
|
||
...(damagedClaims as any[]),
|
||
...snapshotDamagedClaims,
|
||
]) {
|
||
byId.set(String(c._id), c);
|
||
}
|
||
claims = [...byId.values()];
|
||
}
|
||
const list = (claims as any[]).map((c) => ({
|
||
claimRequestId: c._id.toString(),
|
||
publicId: c.publicId,
|
||
requestNo: c.requestNo,
|
||
status: c.status,
|
||
claimStatus: c.claimStatus || "PENDING",
|
||
currentStep: c.workflow?.currentStep || "",
|
||
createdAt: c.createdAt,
|
||
blameRequestId: c.blameRequestId?.toString(),
|
||
})) as ClaimListItemV2Dto[];
|
||
|
||
const paged = applyListQueryV2(
|
||
list,
|
||
{
|
||
publicId: (r) => r.publicId,
|
||
createdAt: (r) => r.createdAt,
|
||
requestNo: (r) => r.requestNo,
|
||
status: (r) => r.status,
|
||
searchExtras: (r) =>
|
||
[
|
||
r.claimRequestId,
|
||
r.claimStatus,
|
||
r.currentStep,
|
||
r.blameRequestId,
|
||
].filter(Boolean) as string[],
|
||
},
|
||
query,
|
||
);
|
||
|
||
return {
|
||
list: paged.list,
|
||
total: paged.total,
|
||
page: paged.page,
|
||
limit: paged.limit,
|
||
totalPages: paged.totalPages,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error("getMyClaimsV2 failed", error);
|
||
throw new InternalServerErrorException("Failed to get claims list");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Get claim details by ID (owner — or FIELD_EXPERT with enriched money/rating fields).
|
||
* Owners receive `ownerGuidance` computed from workflow state.
|
||
*/
|
||
async getClaimDetailsV2(
|
||
claimRequestId: string,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<ClaimDetailsV2ResponseDto> {
|
||
try {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
await this.assertActorCanViewClaimV2(claim, currentUserId, actor);
|
||
|
||
const hasCapture = (data: any, key: string) =>
|
||
data && (data instanceof Map ? data.get(key) : data[key]);
|
||
|
||
const requiredDocs = claim.requiredDocuments as any;
|
||
const requiredDocumentsStatus: Record<
|
||
string,
|
||
{ uploaded: boolean; fileUrl?: string }
|
||
> = {};
|
||
if (requiredDocs) {
|
||
const keys =
|
||
requiredDocs instanceof Map
|
||
? Array.from(requiredDocs.keys())
|
||
: Object.keys(requiredDocs);
|
||
for (const k of keys) {
|
||
const doc =
|
||
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
|
||
requiredDocumentsStatus[k] = {
|
||
uploaded: !!doc?.uploaded,
|
||
fileUrl: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
const carAnglesData = claim.media?.carAngles as any;
|
||
const damagedPartsForAngles = claim.media?.damagedParts as any;
|
||
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
||
for (const k of ["front", "back", "left", "right"]) {
|
||
const cap = getClaimCarAngleCaptureBlob(
|
||
carAnglesData,
|
||
damagedPartsForAngles,
|
||
k as ClaimCarAngleKey,
|
||
) as { url?: string; path?: string } | undefined;
|
||
carAngles[k] = {
|
||
captured: !!cap,
|
||
url: resolveStoredFileUrl(cap),
|
||
};
|
||
}
|
||
|
||
const carTypeDetails = claim.vehicle?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
const selectedNormDetails = normalizeDamageSelectedParts(
|
||
claim.damage?.selectedParts,
|
||
carTypeDetails,
|
||
(claim.damage as any)?.selectedOuterParts,
|
||
);
|
||
const damagedPartsData = claim.media?.damagedParts as any;
|
||
const resendPartKeys = normalizeResendPartKeys(
|
||
claim.evaluation?.damageExpertResend?.resendCarParts,
|
||
carTypeDetails,
|
||
claim.damage?.selectedParts,
|
||
);
|
||
const displayParts = [...selectedNormDetails];
|
||
const seenDetailKeys = new Set(
|
||
selectedNormDetails.map((p) => partLookupKey(p)),
|
||
);
|
||
for (const rk of resendPartKeys) {
|
||
const extra = normalizeDamageSelectedParts(
|
||
[rk],
|
||
carTypeDetails,
|
||
undefined,
|
||
)[0] || {
|
||
id: null,
|
||
name: rk,
|
||
side: "",
|
||
label_fa: rk,
|
||
catalogKey: rk,
|
||
};
|
||
const k = partLookupKey(extra);
|
||
if (!seenDetailKeys.has(k)) {
|
||
displayParts.push(extra);
|
||
seenDetailKeys.add(k);
|
||
}
|
||
}
|
||
|
||
const damagedParts = buildEnrichedDamagedParts({
|
||
selectedParts: displayParts,
|
||
damagedPartsData: damagedPartsData,
|
||
evaluationBlock: (claim as any).evaluation,
|
||
expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
|
||
getDamagedPartCaptureBlob,
|
||
hasDamagedPartCapture,
|
||
catalogLikeKeyFromPart,
|
||
buildFileLink,
|
||
});
|
||
|
||
const er = claim.evaluation?.damageExpertResend;
|
||
const expertResend =
|
||
er &&
|
||
(er.resendDescription ||
|
||
(er.resendDocuments?.length ?? 0) > 0 ||
|
||
(er.resendCarParts?.length ?? 0) > 0)
|
||
? {
|
||
resendDescription: er.resendDescription,
|
||
resendDocuments: normalizeResendDocumentKeys(er.resendDocuments),
|
||
resendCarParts: mapExpertResendCarPartsForClient(
|
||
er.resendCarParts,
|
||
{
|
||
carType: carTypeDetails,
|
||
selectedParts: claim.damage?.selectedParts,
|
||
damagedPartsData: claim.media?.damagedParts,
|
||
buildUrl: (path) => buildFileLink(path),
|
||
},
|
||
),
|
||
fulfilledAt: er.fulfilledAt,
|
||
}
|
||
: undefined;
|
||
|
||
const maskSheba = (s?: string) =>
|
||
s ? s.replace(/^(.{4})(.*)(.{4})$/, "IR$1************$3") : undefined;
|
||
const maskNationalCode = (s?: string) =>
|
||
s ? s.replace(/^(.{2})(.*)(.{2})$/, "$1******$3") : undefined;
|
||
const isExpertViewer =
|
||
actor?.role === RoleEnum.FIELD_EXPERT ||
|
||
actor?.role === RoleEnum.REGISTRAR;
|
||
const ownerData = claim.owner
|
||
? {
|
||
userId: claim.owner.userId?.toString(),
|
||
clientId: claim.owner.clientId?.toString(),
|
||
...(claim.owner.userClientKey
|
||
? { userClientKey: claim.owner.userClientKey.toString() }
|
||
: {}),
|
||
fullName: claim.owner.fullName,
|
||
}
|
||
: undefined;
|
||
const moneyForUser = claim.money
|
||
? {
|
||
sheba: maskSheba(claim.money.sheba),
|
||
nationalCodeOfOwner: maskNationalCode(
|
||
claim.money.nationalCodeOfInsurer,
|
||
),
|
||
}
|
||
: undefined;
|
||
const moneyForExpert = claim.money
|
||
? {
|
||
sheba: claim.money.sheba,
|
||
nationalCodeOfOwner: claim.money.nationalCodeOfInsurer,
|
||
}
|
||
: undefined;
|
||
|
||
const mappedEvaluation = await this.mapEvaluationForClient(
|
||
claim.evaluation,
|
||
claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||
);
|
||
|
||
return {
|
||
claimRequestId: claim._id.toString(),
|
||
publicId: claim.publicId,
|
||
requestNo: claim.requestNo,
|
||
status: claim.status,
|
||
claimStatus: claim.claimStatus || "PENDING",
|
||
currentStep: claim.workflow?.currentStep || "",
|
||
nextStep: claim.workflow?.nextStep,
|
||
blameRequestId: claim.blameRequestId?.toString(),
|
||
blameRequestNo: claim.blameRequestNo,
|
||
...(ownerData ? { owner: ownerData } : {}),
|
||
vehicle: claim.vehicle,
|
||
selectedParts: selectedNormDetails,
|
||
otherParts: claim.damage?.otherParts,
|
||
money: isExpertViewer ? moneyForExpert : moneyForUser,
|
||
requiredDocuments:
|
||
Object.keys(requiredDocumentsStatus).length > 0
|
||
? requiredDocumentsStatus
|
||
: undefined,
|
||
carAngles,
|
||
damagedParts,
|
||
expertResend,
|
||
evaluation: mappedEvaluation
|
||
? {
|
||
damageExpertReply: mappedEvaluation.damageExpertReply,
|
||
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
|
||
}
|
||
: undefined,
|
||
...(!isExpertViewer
|
||
? {
|
||
ownerGuidance: buildClaimDetailsV2OwnerGuidance(
|
||
claim,
|
||
claim._id.toString(),
|
||
),
|
||
}
|
||
: {}),
|
||
...(isExpertViewer
|
||
? {
|
||
userRating: claim.userRating
|
||
? {
|
||
progressSpeed: claim.userRating.progressSpeed,
|
||
registrationEase: claim.userRating.registrationEase,
|
||
overallEvaluation: claim.userRating.overallEvaluation,
|
||
comment: claim.userRating.comment,
|
||
createdAt: claim.userRating.createdAt,
|
||
}
|
||
: undefined,
|
||
}
|
||
: {}),
|
||
createdAt: (claim as any).createdAt,
|
||
updatedAt: (claim as any).updatedAt,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error("getClaimDetailsV2 failed", error);
|
||
throw new InternalServerErrorException("Failed to get claim details");
|
||
}
|
||
}
|
||
|
||
private async generateUniqueClaimNumber(): Promise<string> {
|
||
const prefix = "CL";
|
||
const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits
|
||
return `${prefix}${randomPart}`;
|
||
}
|
||
|
||
private async loadBlameForV3Claim(claimCase: any) {
|
||
if (!claimCase?.blameRequestId) return null;
|
||
return this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId.toString(),
|
||
);
|
||
}
|
||
|
||
private hasPartyInquiriesOnBlame(
|
||
blame: any,
|
||
role: "FIRST" | "SECOND",
|
||
): boolean {
|
||
const step =
|
||
role === "FIRST"
|
||
? WorkflowStep.FIRST_INITIAL_FORM
|
||
: WorkflowStep.SECOND_INITIAL_FORM;
|
||
return (blame.workflow?.completedSteps ?? []).includes(step);
|
||
}
|
||
|
||
private partyHasSignedOnBlame(blame: any, role: "FIRST" | "SECOND"): boolean {
|
||
const party = (blame.parties ?? []).find((p: any) => p?.role === role);
|
||
return !!party?.confirmation;
|
||
}
|
||
|
||
private assertV3ClaimDocumentsPhase(
|
||
blame: any,
|
||
options?: { skipAccidentFieldsCheck?: boolean },
|
||
): void {
|
||
if (
|
||
!options?.skipAccidentFieldsCheck &&
|
||
!(blame.expert?.decision as any)?.fields?.accidentWay
|
||
) {
|
||
throw new BadRequestException(
|
||
"Submit accident fields before uploading required documents.",
|
||
);
|
||
}
|
||
const isCarBody =
|
||
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
|
||
if (isCarBody) {
|
||
if (!this.partyHasSignedOnBlame(blame, "FIRST")) {
|
||
throw new BadRequestException(
|
||
"Upload documents after the party has signed.",
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (
|
||
!this.partyHasSignedOnBlame(blame, "FIRST") ||
|
||
!this.partyHasSignedOnBlame(blame, "SECOND")
|
||
) {
|
||
throw new BadRequestException(
|
||
"Both parties must sign before uploading required documents.",
|
||
);
|
||
}
|
||
}
|
||
|
||
private assertV3ClaimPartsPhase(blame: any): void {
|
||
if (!(blame.expert?.decision as any)?.fields?.accidentWay) {
|
||
throw new BadRequestException(
|
||
"Submit accident fields before part selection.",
|
||
);
|
||
}
|
||
const isCarBody =
|
||
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
|
||
if (isCarBody) {
|
||
if (!this.partyHasSignedOnBlame(blame, "FIRST")) {
|
||
throw new BadRequestException(
|
||
"Part selection requires the party signature.",
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (
|
||
!this.partyHasSignedOnBlame(blame, "FIRST") ||
|
||
!this.partyHasSignedOnBlame(blame, "SECOND")
|
||
) {
|
||
throw new BadRequestException(
|
||
"Both parties must sign before part selection.",
|
||
);
|
||
}
|
||
}
|
||
|
||
private claimV3StepCompleted(
|
||
claimCase: any,
|
||
step: ClaimWorkflowStep,
|
||
): boolean {
|
||
return (claimCase.workflow?.completedSteps ?? []).includes(step);
|
||
}
|
||
|
||
private assertV3ClaimWorkflowStep(
|
||
claimCase: any,
|
||
expected: ClaimWorkflowStep | ClaimWorkflowStep[],
|
||
hint?: string,
|
||
): void {
|
||
const allowed = Array.isArray(expected) ? expected : [expected];
|
||
const current = claimCase.workflow?.currentStep;
|
||
if (!allowed.includes(current)) {
|
||
throw new BadRequestException(
|
||
hint ??
|
||
`Invalid workflow step. Expected ${allowed.join(" or ")}, but current step is ${current}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async advanceV3ClaimToOuterPartsIfReady(
|
||
claimCase: any,
|
||
blame: any,
|
||
): Promise<void> {
|
||
if (
|
||
claimCase.workflow?.currentStep === ClaimWorkflowStep.SELECT_OUTER_PARTS
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const step = claimCase.workflow?.currentStep;
|
||
// UPLOAD_REQUIRED_DOCUMENTS: normal V3/V4 path after FileMaker docs.
|
||
// WAITING_FOR_FILE_REVIEWER (claim status): same DB step but with the new
|
||
// V4 claim status — FileReviewer is picking up the sealed file.
|
||
// USER_SUBMISSION_COMPLETE: can occur when documents were uploaded via a
|
||
// non-V3 path (e.g. generic v2 endpoint). Safe to advance as long as
|
||
// accident fields are set (checked just below).
|
||
const canAdvance =
|
||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS ||
|
||
step === ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ||
|
||
claimCase.status === ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER;
|
||
|
||
if (!canAdvance) {
|
||
throw new BadRequestException(
|
||
`Complete required documents before selecting outer parts. Current step: ${step}`,
|
||
);
|
||
}
|
||
if (!(blame.expert?.decision as any)?.fields?.accidentWay) {
|
||
throw new BadRequestException(
|
||
"Submit accident fields before selecting outer parts.",
|
||
);
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(String(claimCase._id), {
|
||
$set: {
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
"workflow.currentStep": ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
"workflow.nextStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
},
|
||
});
|
||
}
|
||
|
||
private async assertV3InPersonClaim(claimCase: any) {
|
||
const blame = await this.loadBlameForV3Claim(claimCase);
|
||
if (!blame) {
|
||
throw new BadRequestException(
|
||
"V3 claim flow requires a linked blame file.",
|
||
);
|
||
}
|
||
if (blame.creationMethod !== CreationMethod.IN_PERSON) {
|
||
throw new BadRequestException(
|
||
"V3 claim flow is only for IN_PERSON blame files.",
|
||
);
|
||
}
|
||
if (!blame.expertInitiated) {
|
||
throw new BadRequestException(
|
||
"V3 claim flow requires an expert-initiated blame file.",
|
||
);
|
||
}
|
||
return blame;
|
||
}
|
||
|
||
private isV3InitialDocumentsPhase(claimCase: any): boolean {
|
||
const step = claimCase.workflow?.currentStep;
|
||
const capturePartDone = this.claimV3StepCompleted(
|
||
claimCase,
|
||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
);
|
||
return (
|
||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS && !capturePartDone
|
||
);
|
||
}
|
||
|
||
private shapeCaptureRequirementsForV3(
|
||
claimCase: any,
|
||
_blame: any,
|
||
base: GetCaptureRequirementsV2ResponseDto,
|
||
): GetCaptureRequirementsV2ResponseDto {
|
||
const step = claimCase.workflow?.currentStep;
|
||
const capturePartDone = this.claimV3StepCompleted(
|
||
claimCase,
|
||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
);
|
||
|
||
if (this.isV3InitialDocumentsPhase(claimCase)) {
|
||
const preCaptureDocs = base.requiredDocuments.filter(
|
||
(d) => !d.preferUploadDuringCapture,
|
||
);
|
||
const remaining = preCaptureDocs.filter((d) => !d.uploaded).length;
|
||
return {
|
||
...base,
|
||
requiredDocuments: preCaptureDocs,
|
||
captureSequencePhase: "parts",
|
||
captureSequenceHint:
|
||
"Upload initial required documents (licenses and car cards). Chassis, engine, and metal plate photos come after part selection, damaged-part photos, and car angles.",
|
||
damagedParts: [],
|
||
carAngles: base.carAngles.map((a) => ({ ...a, captured: false })),
|
||
totalRemaining: remaining,
|
||
progress: {
|
||
...base.progress,
|
||
documentsTotal: preCaptureDocs.length,
|
||
documentsUploaded: preCaptureDocs.filter((d) => d.uploaded).length,
|
||
partsCaptured: 0,
|
||
partsTotal: 0,
|
||
anglesCaptured: 0,
|
||
capturePhaseDocsRemaining: 0,
|
||
postCaptureDocumentsRemaining: remaining,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (step === ClaimWorkflowStep.SELECT_OUTER_PARTS) {
|
||
return {
|
||
...base,
|
||
requiredDocuments: base.requiredDocuments.filter(
|
||
(d) => !d.preferUploadDuringCapture,
|
||
),
|
||
captureSequencePhase: "parts",
|
||
captureSequenceHint: "Select outer damaged parts before capture.",
|
||
totalRemaining: base.damagedParts.filter((p) => !p.captured).length,
|
||
};
|
||
}
|
||
|
||
if (step === ClaimWorkflowStep.SELECT_OTHER_PARTS) {
|
||
return {
|
||
...base,
|
||
requiredDocuments: base.requiredDocuments.filter(
|
||
(d) => !d.preferUploadDuringCapture,
|
||
),
|
||
captureSequencePhase: "parts",
|
||
captureSequenceHint:
|
||
"Select other damaged parts (and car green card when required) before capture.",
|
||
};
|
||
}
|
||
|
||
if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES) {
|
||
const capturePhaseDocs = base.requiredDocuments.filter(
|
||
(d) => d.preferUploadDuringCapture,
|
||
);
|
||
return {
|
||
...base,
|
||
requiredDocuments: capturePhaseDocs,
|
||
totalRemaining: base.progress.capturePhaseDocsRemaining,
|
||
};
|
||
}
|
||
|
||
if (
|
||
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS &&
|
||
capturePartDone
|
||
) {
|
||
return {
|
||
...base,
|
||
requiredDocuments: [],
|
||
captureSequencePhase: "complete",
|
||
captureSequenceHint:
|
||
"Upload the walk-around car video (car-capture), then the blame accident video.",
|
||
totalRemaining: claimCase.media?.videoCaptureId ? 0 : 1,
|
||
};
|
||
}
|
||
|
||
return base;
|
||
}
|
||
|
||
async uploadRequiredDocumentV3(
|
||
claimRequestId: string,
|
||
body: UploadRequiredDocumentV2Dto,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
const blame = await this.assertV3InPersonClaim(claimCase);
|
||
|
||
const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey);
|
||
|
||
if (isCaptureDoc) {
|
||
this.assertV3ClaimPartsPhase(blame);
|
||
this.assertV3ClaimWorkflowStep(
|
||
claimCase,
|
||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
"Upload chassis, engine, and metal plate photos during capture after damaged-part photos and car angles.",
|
||
);
|
||
if (
|
||
!this.claimV3StepCompleted(
|
||
claimCase,
|
||
ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
)
|
||
) {
|
||
throw new BadRequestException(
|
||
"Complete outer and other part selection before capture-phase vehicle documents.",
|
||
);
|
||
}
|
||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||
if (!captureProgress.partsComplete) {
|
||
throw new BadRequestException(
|
||
"Upload photos for all selected damaged parts before chassis, engine, or metal plate photos.",
|
||
);
|
||
}
|
||
if (!captureProgress.anglesComplete) {
|
||
throw new BadRequestException(
|
||
"Capture all four car angles before chassis, engine, or metal plate photos.",
|
||
);
|
||
}
|
||
} else {
|
||
this.assertV3ClaimDocumentsPhase(blame, {
|
||
skipAccidentFieldsCheck: actor?.role === "file_maker",
|
||
});
|
||
if (!this.isV3InitialDocumentsPhase(claimCase)) {
|
||
throw new BadRequestException(
|
||
`Upload initial required documents during ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} before part selection. Current step: ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return this.uploadRequiredDocumentV2(
|
||
claimRequestId,
|
||
body,
|
||
file,
|
||
currentUserId,
|
||
actor,
|
||
{ v3InPersonFlow: true },
|
||
);
|
||
}
|
||
|
||
async getCaptureRequirementsV3(
|
||
claimRequestId: string,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
const blame = await this.assertV3InPersonClaim(claimCase);
|
||
|
||
if (this.isV3InitialDocumentsPhase(claimCase)) {
|
||
this.assertV3ClaimDocumentsPhase(blame, {
|
||
skipAccidentFieldsCheck: actor?.role === "file_maker",
|
||
});
|
||
} else if (
|
||
claimCase.workflow?.currentStep === ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
||
) {
|
||
this.assertV3ClaimPartsPhase(blame);
|
||
}
|
||
|
||
const base = await this.getCaptureRequirementsV2(
|
||
claimRequestId,
|
||
currentUserId,
|
||
actor,
|
||
);
|
||
return this.shapeCaptureRequirementsForV3(claimCase, blame, base);
|
||
}
|
||
|
||
async selectOuterPartsV3(
|
||
claimRequestId: string,
|
||
body: SelectOuterPartsV2Dto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
const blame = await this.assertV3InPersonClaim(claimCase);
|
||
this.assertV3ClaimPartsPhase(blame);
|
||
await this.advanceV3ClaimToOuterPartsIfReady(claimCase, blame);
|
||
|
||
const refreshed = await this.claimCaseDbService.findById(claimRequestId);
|
||
this.assertV3ClaimWorkflowStep(
|
||
refreshed,
|
||
ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
"Select outer parts after accident fields and required documents.",
|
||
);
|
||
|
||
return this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor);
|
||
}
|
||
|
||
async selectOtherPartsV3(
|
||
claimRequestId: string,
|
||
body: SelectOtherPartsV3Dto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
const blame = await this.assertV3InPersonClaim(claimCase);
|
||
this.assertV3ClaimPartsPhase(blame);
|
||
this.assertV3ClaimWorkflowStep(
|
||
claimCase,
|
||
ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
"Select outer parts before other parts.",
|
||
);
|
||
if (
|
||
!this.claimV3StepCompleted(
|
||
claimCase,
|
||
ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
)
|
||
) {
|
||
throw new BadRequestException(
|
||
"Complete outer part selection before selecting other parts.",
|
||
);
|
||
}
|
||
|
||
let otherParts = Array.isArray((body as any)?.otherParts)
|
||
? (body as any).otherParts
|
||
: [];
|
||
if (
|
||
!Array.isArray((body as any)?.otherParts) &&
|
||
typeof (body as any)?.otherParts === "string"
|
||
) {
|
||
try {
|
||
const parsed = JSON.parse((body as any).otherParts);
|
||
otherParts = Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
otherParts = [];
|
||
}
|
||
}
|
||
|
||
const shebaNumber = claimCase.money?.sheba;
|
||
const nationalCode = claimCase.money?.nationalCodeOfInsurer;
|
||
if (!shebaNumber || !nationalCode) {
|
||
throw new BadRequestException(
|
||
"Bank information is missing on the claim. Complete run-inquiries for both parties first.",
|
||
);
|
||
}
|
||
|
||
const updatePayload: any = {
|
||
"damage.otherParts": otherParts.length > 0 ? otherParts : undefined,
|
||
status: ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
history: {
|
||
type: "V3_STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "Actor",
|
||
actorType:
|
||
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
otherParts,
|
||
v3SelectionOnly: true,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
updatePayload,
|
||
);
|
||
if (!updatedClaim) {
|
||
throw new InternalServerErrorException("Failed to update claim case");
|
||
}
|
||
|
||
return {
|
||
claimRequestId: updatedClaim._id.toString(),
|
||
publicId: updatedClaim.publicId,
|
||
otherParts,
|
||
shebaNumber: String(shebaNumber).replace(
|
||
/^(.{4})(.*)(.{4})$/,
|
||
"IR$1************$3",
|
||
),
|
||
nationalCodeOfOwner: String(nationalCode).replace(
|
||
/^(.{2})(.*)(.{2})$/,
|
||
"$1******$3",
|
||
),
|
||
currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
message:
|
||
"Other parts saved. Proceed to damaged-part photos and car angles (capture-part).",
|
||
};
|
||
}
|
||
|
||
async setVideoCaptureV3(
|
||
claimRequestId: string,
|
||
fileDetail: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<VideoCaptureV2ResponseDto> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
await this.assertV3InPersonClaim(claimCase);
|
||
|
||
if (!fileDetail) {
|
||
throw new BadRequestException("Video file is required.");
|
||
}
|
||
if (claimCase.media?.videoCaptureId) {
|
||
throw new ConflictException(
|
||
"A walk-around video has already been uploaded for this claim.",
|
||
);
|
||
}
|
||
|
||
if (
|
||
claimCase.workflow?.currentStep !==
|
||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||
) {
|
||
throw new BadRequestException(
|
||
`Complete all part/angle captures first. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
|
||
const captureProgress = getClaimCaptureProgress(claimCase);
|
||
if (!captureProgress.partsComplete) {
|
||
throw new BadRequestException(
|
||
"Upload photos for all selected damaged parts before the walk-around video.",
|
||
);
|
||
}
|
||
if (!captureProgress.anglesComplete) {
|
||
throw new BadRequestException(
|
||
"Capture all four car angles before the walk-around video.",
|
||
);
|
||
}
|
||
if (!isClaimCaptureStepComplete(claimCase)) {
|
||
throw new BadRequestException(
|
||
"Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.",
|
||
);
|
||
}
|
||
|
||
return this.setVideoCaptureV2(
|
||
claimRequestId,
|
||
fileDetail,
|
||
currentUserId,
|
||
actor,
|
||
);
|
||
}
|
||
|
||
async capturePartV3(
|
||
claimRequestId: string,
|
||
body: CapturePartV2Dto,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<CapturePartV2ResponseDto> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(
|
||
`Claim case with ID ${claimRequestId} not found`,
|
||
);
|
||
}
|
||
const blame = await this.assertV3InPersonClaim(claimCase);
|
||
this.assertV3ClaimPartsPhase(blame);
|
||
this.assertV3ClaimWorkflowStep(
|
||
claimCase,
|
||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
"Select other parts before capturing damaged parts and angles.",
|
||
);
|
||
if (
|
||
!this.claimV3StepCompleted(
|
||
claimCase,
|
||
ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
)
|
||
) {
|
||
throw new BadRequestException(
|
||
"Complete other part selection before capture.",
|
||
);
|
||
}
|
||
|
||
return this.capturePartV2(claimRequestId, body, file, currentUserId, actor);
|
||
}
|
||
}
|