From 6791c71809ec74c7deabd03469e08542d835a6a8 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 10 Aug 2026 13:48:05 +0330 Subject: [PATCH] Added vin inquiry for v6, fixed new damaged part for v4 --- .../required-document-type.enum.ts | 6 + .../claim-request-management.service.ts | 125 +++++++++++++----- src/helpers/claim-capture-phase.ts | 29 ++-- .../call-center-blame-v6.controller.ts | 26 +++- .../dto/run-call-center-inquiry-v6.dto.ts | 58 ++++++++ .../request-management.service.ts | 92 +++++++++++++ 6 files changed, 292 insertions(+), 44 deletions(-) diff --git a/src/Types&Enums/claim-request-management/required-document-type.enum.ts b/src/Types&Enums/claim-request-management/required-document-type.enum.ts index 5f6312e..841ab1a 100644 --- a/src/Types&Enums/claim-request-management/required-document-type.enum.ts +++ b/src/Types&Enums/claim-request-management/required-document-type.enum.ts @@ -20,6 +20,12 @@ export enum ClaimRequiredDocumentType { GUILTY_CAR_CARD_FRONT = "guilty_car_card_front", GUILTY_CAR_CARD_BACK = "guilty_car_card_back", GUILTY_METAL_PLATE = "guilty_metal_plate", + + /** + * V4/V5 only — a photo of the guilty car's damaged area, captured by the + * FileReviewer during the CAPTURE_PART_DAMAGES phase (after all car angles). + */ + GUILTY_DAMAGE_AREA = "guilty_damage_area", } export enum CarAngle { diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 3ca7bca..9d6f66f 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -163,6 +163,7 @@ import { CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, capturePhaseSequenceMessage, getClaimCaptureProgress, + GUILTY_DAMAGE_AREA_DOC_KEY, isCapturePhaseDamagedPartyDocKey, isClaimCaptureStepComplete, OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5, @@ -9073,7 +9074,7 @@ export class ClaimRequestManagementService { file: Express.Multer.File, currentUserId: string, actor?: { sub: string; role?: string }, - options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean }, + options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean; requiresFileMakerApproval?: boolean; includeGuiltyDamageArea?: boolean }, ): Promise { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); @@ -9099,7 +9100,9 @@ export class ClaimRequestManagementService { const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; const isCapturePhaseDocUpload = step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && - isCapturePhaseDamagedPartyDocKey(body.documentKey); + (isCapturePhaseDamagedPartyDocKey(body.documentKey) || + (options?.includeGuiltyDamageArea && + (body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY)); if (!isResendUpload) { if ( @@ -9249,17 +9252,20 @@ export class ClaimRequestManagementService { const afterThis = (k: string) => k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k); - remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( - (k) => { - if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; - return !afterThis(k); - }, - ).length; + const captureKeysForCount: string[] = [ + ...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, + ...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []), + ]; + remaining = captureKeysForCount.filter((k) => { + if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; + return !afterThis(k); + }).length; if (remaining === 0) { const progressAfterDoc = getClaimCaptureProgress(claimCase, { assumeCapturePhaseDocKey: body.documentKey, skipMetalPlate: options?.skipMetalPlate, + includeGuiltyDamageArea: options?.includeGuiltyDamageArea, }); if ( @@ -11241,6 +11247,13 @@ export class ClaimRequestManagementService { base: GetCaptureRequirementsV2ResponseDto, ): GetCaptureRequirementsV2ResponseDto { const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker; + // V4 = FileMaker flow without a secondary FileMaker approval step + const isV4 = + !!(blame as any)?.isMadeByFileMaker && + !(claimCase as any)?.requiresFileMakerApproval; + const isCarBody = + blame?.type === BlameRequestType.CAR_BODY || + (blame as any)?.type === "CAR_BODY"; // For V4/V5 files strip metal-plate keys from the base response entirely // so they never appear in any phase — the front-end should not show them. @@ -11316,6 +11329,51 @@ export class ClaimRequestManagementService { d.preferUploadDuringCapture && !(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)), ); + + // V4 THIRD_PARTY only: guilty_damage_area is uploaded via upload-document + // after all car angles are captured (last item in the documents phase). + // It also appears in damagedParts so the expert sees it labelled as a + // damaged-part capture. + if (isV4 && !isCarBody) { + const guiltyAreaUploaded = this.isRequiredDocumentUploadedOnClaim( + claimCase, + GUILTY_DAMAGE_AREA_DOC_KEY, + ); + const guiltyAreaDoc = { + key: GUILTY_DAMAGE_AREA_DOC_KEY, + label_fa: "تصویر نقطه آسیب‌دیده مقصر", + label_en: "Guilty Car Damaged Area", + category: "guilty_party", + uploaded: guiltyAreaUploaded, + preferUploadDuringCapture: true, + }; + const guiltyAreaPart = { + id: null as any, + key: GUILTY_DAMAGE_AREA_DOC_KEY, + name: GUILTY_DAMAGE_AREA_DOC_KEY, + side: "", + label_fa: "تصویر نقطه آسیب‌دیده مقصر", + label_en: "Guilty Car Damaged Area", + captured: guiltyAreaUploaded, + }; + + const captureProgress = getClaimCaptureProgress(claimCase, { + skipMetalPlate, + includeGuiltyDamageArea: true, + }); + + return { + ...base, + requiredDocuments: [...capturePhaseDocs, guiltyAreaDoc], + damagedParts: [...base.damagedParts, guiltyAreaPart], + totalRemaining: captureProgress.capturePhaseDocsRemaining, + progress: { + ...base.progress, + capturePhaseDocsRemaining: captureProgress.capturePhaseDocsRemaining, + }, + }; + } + return { ...base, requiredDocuments: capturePhaseDocs, @@ -11355,8 +11413,21 @@ export class ClaimRequestManagementService { } const blame = await this.assertV3InPersonClaim(claimCase); const skipMetalPlate = !!(blame as any).isMadeByFileMaker; + const isV4FileMaker = + !!(blame as any)?.isMadeByFileMaker && + !(claimCase as any)?.requiresFileMakerApproval; + const isCarBodyBlame = + blame?.type === BlameRequestType.CAR_BODY || + (blame as any)?.type === "CAR_BODY"; - const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey); + // guilty_damage_area is an extra capture-phase doc key for V4 THIRD_PARTY + const isGuiltyDamageAreaDoc = + (body.documentKey as string) === GUILTY_DAMAGE_AREA_DOC_KEY && + isV4FileMaker && + !isCarBodyBlame; + + const isCaptureDoc = + isCapturePhaseDamagedPartyDocKey(body.documentKey) || isGuiltyDamageAreaDoc; if (isCaptureDoc) { this.assertV3ClaimPartsPhase(blame); @@ -11375,7 +11446,10 @@ export class ClaimRequestManagementService { "Complete outer and other part selection before capture-phase vehicle documents.", ); } - const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate }); + const captureProgress = getClaimCaptureProgress(claimCase, { + skipMetalPlate, + includeGuiltyDamageArea: isGuiltyDamageAreaDoc, + }); if (!captureProgress.partsComplete) { throw new BadRequestException( "Upload photos for all selected damaged parts before chassis or engine photos.", @@ -11403,7 +11477,12 @@ export class ClaimRequestManagementService { file, currentUserId, actor, - { v3InPersonFlow: true, skipMetalPlate, requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval }, + { + v3InPersonFlow: true, + skipMetalPlate, + requiresFileMakerApproval: !!(claimCase as any).requiresFileMakerApproval, + includeGuiltyDamageArea: isGuiltyDamageAreaDoc, + }, ); } @@ -11463,30 +11542,6 @@ export class ClaimRequestManagementService { const result = await this.selectOuterPartsV2(claimRequestId, body, currentUserId, actor); - // V4 THIRD_PARTY only: append a synthetic "guilty damage area" entry to - // damage.selectedParts so the FileReviewer must capture one photo of the - // guilty party's damaged spot during the CAPTURE_PART_DAMAGES phase. - // selectOuterPartsV2 uses $set on damage.selectedParts, so we append here - // with a separate $push after the V2 write completes. - const isV4 = - !!(blame as any)?.isMadeByFileMaker && - !(refreshed as any)?.requiresFileMakerApproval; - const isCarBodyBlame = - blame?.type === BlameRequestType.CAR_BODY || - (blame as any)?.type === "CAR_BODY"; - if (isV4 && !isCarBodyBlame) { - await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { - $push: { - "damage.selectedParts": { - id: null, - name: "guilty_damage_area", - side: "", - label_fa: "تصویر نقطه آسیب‌دیده مقصر", - }, - }, - }); - } - return result; } diff --git a/src/helpers/claim-capture-phase.ts b/src/helpers/claim-capture-phase.ts index 43e5046..89f92fc 100644 --- a/src/helpers/claim-capture-phase.ts +++ b/src/helpers/claim-capture-phase.ts @@ -22,6 +22,12 @@ export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [ "guilty_metal_plate", ] as const; +/** + * Extra capture-phase document key present only in V4/V5 (FileMaker) flows. + * Uploaded via `upload-document` after all car angles are captured. + */ +export const GUILTY_DAMAGE_AREA_DOC_KEY = "guilty_damage_area" as const; + export type CapturePhaseSequence = | "parts" | "angles" @@ -58,7 +64,12 @@ function isRequiredDocumentUploadedOnClaim( export function getClaimCaptureProgress( claimCase: any, - options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean }, + options?: { + assumeCapturePhaseDocKey?: string; + skipMetalPlate?: boolean; + /** V4/V5 only — include `guilty_damage_area` in capture-phase doc count. */ + includeGuiltyDamageArea?: boolean; + }, ): ClaimCaptureProgress { const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const selectedNorm = normalizeDamageSelectedParts( @@ -87,13 +98,15 @@ export function getClaimCaptureProgress( const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length; const anglesComplete = anglesCaptured >= anglesTotal; - const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( - (k) => { - if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; - if (k === options?.assumeCapturePhaseDocKey) return false; - return !isRequiredDocumentUploadedOnClaim(claimCase, k); - }, - ).length; + const capturePhaseKeys: string[] = [ + ...CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, + ...(options?.includeGuiltyDamageArea ? [GUILTY_DAMAGE_AREA_DOC_KEY] : []), + ]; + const capturePhaseDocsRemaining = capturePhaseKeys.filter((k) => { + if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false; + if (k === options?.assumeCapturePhaseDocKey) return false; + return !isRequiredDocumentUploadedOnClaim(claimCase, k); + }).length; const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0; let sequencePhase: CapturePhaseSequence = "parts"; diff --git a/src/request-management/call-center-blame-v6.controller.ts b/src/request-management/call-center-blame-v6.controller.ts index 4659623..72e0f5e 100644 --- a/src/request-management/call-center-blame-v6.controller.ts +++ b/src/request-management/call-center-blame-v6.controller.ts @@ -20,7 +20,10 @@ import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { RequestManagementService } from "./request-management.service"; -import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto"; +import { + RunCallCenterInquiryV6Dto, + RunCallCenterInquiryVinV6Dto, +} from "./dto/run-call-center-inquiry-v6.dto"; /** * V6 call-center blame API. @@ -105,6 +108,27 @@ export class CallCenterBlameV6Controller { return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto); } + @Post("run-inquiry-vin/:requestId") + @ApiOperation({ + summary: "[V6] Run VIN/chassis inquiry for the guilty party", + description: + "VIN alternative to `run-inquiry`. " + + "The agent supplies the chassis number and personal data collected from the caller. " + + "ESG chassis lookup (`policyByChassis`) is executed and the result is stored on the " + + "blame document under `vehicle.vin` (plateId is left empty). " + + "Identical eligibility guards and insurer-company validation as the plate variant. " + + "After this call, proceed to `send-link` exactly as in the plate flow.", + }) + @ApiParam({ name: "requestId", description: "Blame request ID from `create`" }) + @ApiBody({ type: RunCallCenterInquiryVinV6Dto }) + runInquiryVin( + @CurrentUser() agent: any, + @Param("requestId") requestId: string, + @Body() dto: RunCallCenterInquiryVinV6Dto, + ) { + return this.requestManagementService.runCallCenterInquiryVinV6(agent, requestId, dto); + } + @Post("send-link/:requestId") @ApiOperation({ summary: "[V6] Send blame link to the guilty party via SMS", diff --git a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts index 7e505c5..a201467 100644 --- a/src/request-management/dto/run-call-center-inquiry-v6.dto.ts +++ b/src/request-management/dto/run-call-center-inquiry-v6.dto.ts @@ -4,6 +4,7 @@ import { IsNotEmpty, IsOptional, IsString, + MaxLength, ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; @@ -83,3 +84,60 @@ export class RunCallCenterInquiryV6Dto { @IsString() insurerLicense?: string; } + +/** + * VIN / chassis variant of the V6 call-center inquiry. + * Identical to `RunCallCenterInquiryV6Dto` but replaces `plate` with `vin`. + * Sheba (IBAN) is intentionally absent — the user provides it themselves via the link. + */ +export class RunCallCenterInquiryVinV6Dto { + @ApiProperty({ + example: "NAAM01E15HK123456", + description: "17-character VIN / chassis number (شماره شاسی)", + maxLength: 17, + }) + @IsString() + @IsNotEmpty() + @MaxLength(17) + vin: string; + + @ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" }) + @IsString() + @IsNotEmpty() + nationalCodeOfInsurer: string; + + @ApiProperty({ example: "1234567890", description: "National code of the driver" }) + @IsString() + @IsNotEmpty() + nationalCodeOfDriver: string; + + @ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" }) + @IsBoolean() + driverIsInsurer: boolean; + + @ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" }) + insurerBirthday: number | string; + + @ApiPropertyOptional({ + example: 13780624, + description: "Driver birth date (Jalali). Required when driverIsInsurer is false.", + }) + @IsOptional() + driverBirthday?: number | string | null; + + @ApiPropertyOptional({ + example: "123456789", + description: "Driver license (required when driverIsInsurer is false).", + }) + @IsOptional() + @IsString() + driverLicense?: string; + + @ApiPropertyOptional({ + example: "123456789", + description: "Insurer license (required when driverIsInsurer is true).", + }) + @IsOptional() + @IsString() + insurerLicense?: string; +} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 5d36ab1..d7e4718 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -10953,6 +10953,97 @@ export class RequestManagementService { }; } + /** + * V6 VIN variant: Run VIN/chassis inquiry for the guilty party on behalf of the caller. + * Identical to `runCallCenterInquiryV6` but calls `runPartyInquiriesVinV3Internal` + * (ESG chassis lookup) instead of the plate-based path. + * Returns `vehicle.vin` in the guiltyParty payload instead of `vehicle.plateId`. + */ + async runCallCenterInquiryVinV6( + agent: any, + requestId: string, + dto: Omit, + ): Promise> { + if (agent?.role !== RoleEnum.CALL_CENTER) { + throw new ForbiddenException("Only call-center agents can use this endpoint."); + } + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Blame request not found"); + if (!req.callCenterInitiated || String(req.initiatedByCallCenterId) !== String(agent.sub)) { + throw new ForbiddenException("You can only access files that you have initiated."); + } + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) throw new BadRequestException("First party not found"); + const firstParty = req.parties[firstIdx]; + + await this.runPartyInquiriesVinV3Internal(req, dto as RunInquiriesVinV3Dto, PartyRole.FIRST, firstParty); + + // Same insurer-company guard as the plate variant. + const resolvedClientId = req.parties[firstIdx]?.person?.clientId; + if (resolvedClientId && process.env.CLIENT_ID) { + const resolvedClient = await this.clientService.findOne({ + _id: new Types.ObjectId(String(resolvedClientId)), + }); + if ( + resolvedClient && + String(resolvedClient.clientCode) !== String(process.env.CLIENT_ID) + ) { + throw new ForbiddenException( + "بیمه‌نامه طرف مقصر متعلق به شرکت بیمه این سامانه نیست. لینک تقصیر فقط برای بیمه‌گذاران همین شرکت قابل ارسال است.", + ); + } + } + + if (!Array.isArray((req as any).history)) (req as any).history = []; + (req as any).history.push({ + type: "CALL_CENTER_INQUIRY_COMPLETED", + actor: { + actorId: new Types.ObjectId(agent.sub), + actorName: `${agent.firstName || ""} ${agent.lastName || ""}`.trim(), + actorType: RoleEnum.CALL_CENTER, + }, + metadata: { partyRole: PartyRole.FIRST, inquiryType: "VIN" }, + }); + await (req as any).save(); + + const firstPartyAfter = req.parties[firstIdx]; + return { + blameRequestId: requestId, + publicId: (req as any).publicId, + type: (req as any).type, + status: (req as any).status, + message: "استعلام VIN طرف مقصر با موفقیت انجام شد. اکنون می‌توانید لینک تقصیر را برای کاربر ارسال کنید.", + guiltyParty: { + vehicle: firstPartyAfter?.vehicle + ? { + vin: firstPartyAfter.vehicle.vin, + name: firstPartyAfter.vehicle.name, + type: firstPartyAfter.vehicle.type, + } + : undefined, + insurance: firstPartyAfter?.insurance + ? { + company: firstPartyAfter.insurance.company, + policyNumber: firstPartyAfter.insurance.policyNumber, + startDate: firstPartyAfter.insurance.startDate, + endDate: firstPartyAfter.insurance.endDate, + financialCeiling: (firstPartyAfter.insurance as any).financialCeiling, + } + : undefined, + person: firstPartyAfter?.person + ? { + nationalCodeOfInsurer: firstPartyAfter.person.nationalCodeOfInsurer, + nationalCodeOfDriver: firstPartyAfter.person.nationalCodeOfDriver, + driverIsInsurer: firstPartyAfter.person.driverIsInsurer, + insurerBirthday: formatJalaliCompact(firstPartyAfter.person.insurerBirthday), + driverBirthday: formatJalaliCompact(firstPartyAfter.person.driverBirthday), + } + : undefined, + }, + }; + } + /** * V6: Send the blame link to the guilty party's phone number. * Registers/looks up the user by phone and stores them as the first party, then @@ -11134,6 +11225,7 @@ export class RequestManagementService { vehicle: p.vehicle ? { plateId: p.vehicle.plateId, + vin: p.vehicle.vin, name: p.vehicle.name, type: p.vehicle.type, }