diff --git a/src/auth/guards/actor-local.guard.ts b/src/auth/guards/actor-local.guard.ts index a130590..9c95165 100644 --- a/src/auth/guards/actor-local.guard.ts +++ b/src/auth/guards/actor-local.guard.ts @@ -46,6 +46,7 @@ export class LocalActorAuthGuard implements CanActivate { RoleEnum.FILE_MAKER, RoleEnum.FILE_REVIEWER, RoleEnum.SUPER_ADMIN, + RoleEnum.CALL_CENTER, ].includes(payload.role) ) { throw new UnauthorizedException("User role is not authorized"); diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index d6a77d9..4667138 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -10841,11 +10841,6 @@ export class ClaimRequestManagementService { 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, @@ -10884,14 +10879,22 @@ export class ClaimRequestManagementService { 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", - ), + ...(shebaNumber + ? { + shebaNumber: String(shebaNumber).replace( + /^(.{4})(.*)(.{4})$/, + "IR$1************$3", + ), + } + : {}), + ...(nationalCode + ? { + nationalCodeOfOwner: String(nationalCode).replace( + /^(.{2})(.*)(.{2})$/, + "$1******$3", + ), + } + : {}), currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, message: diff --git a/src/claim-request-management/dto/select-other-parts-v2.dto.ts b/src/claim-request-management/dto/select-other-parts-v2.dto.ts index 9e2f69b..3c635c1 100644 --- a/src/claim-request-management/dto/select-other-parts-v2.dto.ts +++ b/src/claim-request-management/dto/select-other-parts-v2.dto.ts @@ -106,14 +106,16 @@ export class SelectOtherPartsV2ResponseDto { @ApiProperty({ description: 'Sheba number (masked for security)', example: 'IR12************1234', + required: false, }) - shebaNumber: string; + shebaNumber?: string; @ApiProperty({ description: 'National code of owner (masked)', example: '12******90', + required: false, }) - nationalCodeOfOwner: string; + nationalCodeOfOwner?: string; @ApiProperty({ description: 'Current workflow step', diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 5037f40..8e1c841 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -265,6 +265,21 @@ export class ClaimCase { @Prop({ type: Types.ObjectId, index: true }) fileMakerApprovalActorId?: Types.ObjectId; + /** + * V5 split flow: number of times the FileMaker has rejected this claim. + * Capped at 2; on the 3rd attempt the reject endpoint returns 422 and + * the FileMaker must approve instead. + */ + @Prop({ type: Number, default: 0 }) + fileMakerRejectionCount?: number; + + /** + * V5 split flow: reason text from the most recent FileMaker rejection. + * Overwritten on each rejection. + */ + @Prop({ type: String }) + fileMakerRejectionReason?: string; + /** * Legacy fields kept optional to simplify progressive migration. * If you choose to migrate later, we can remove these. diff --git a/src/claim-request-management/expert-initiated-claim.mirror.controller.ts b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts index 9045b22..501ca09 100644 --- a/src/claim-request-management/expert-initiated-claim.mirror.controller.ts +++ b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts @@ -53,6 +53,7 @@ import { CapturePartV2ResponseDto, } from "./dto/capture-part-v2.dto"; import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; +import { Role } from "src/common/auth/enums"; /** * Expert-initiated claim flow that mirrors the normal user claim API @@ -71,7 +72,7 @@ import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements- @Controller("v2/expert-initiated/claim-request-management") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.FIELD_EXPERT) +@Roles(RoleEnum.FIELD_EXPERT, RoleEnum.FILE_MAKER, RoleEnum.FILE_REVIEWER) export class ExpertInitiatedClaimMirrorController { constructor( private readonly claimRequestManagementService: ClaimRequestManagementService, @@ -81,7 +82,8 @@ export class ExpertInitiatedClaimMirrorController { @Post("create-from-blame/:blameRequestId") @ApiParam({ name: "blameRequestId" }) @ApiOperation({ - summary: "[Expert mirror] Create claim from expert-initiated IN_PERSON blame", + summary: + "[Expert mirror] Create claim from expert-initiated IN_PERSON blame", description: "Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.", }) @@ -173,8 +175,7 @@ export class ExpertInitiatedClaimMirrorController { }) @ApiBody({ type: SelectOuterPartsV2Dto, - description: - "Selected vehicle type + selected outer part IDs from catalog", + description: "Selected vehicle type + selected outer part IDs from catalog", examples: { example1: { summary: "Sedan - minor front damage", @@ -211,7 +212,10 @@ export class ExpertInitiatedClaimMirrorController { description: "Outer parts selected successfully", type: SelectOuterPartsV2ResponseDto, }) - @ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" }) + @ApiResponse({ + status: 400, + description: "Invalid workflow step or validation failed", + }) @ApiResponse({ status: 404, description: "Claim case not found" }) @ApiResponse({ status: 409, description: "Outer parts already selected" }) async selectOuterParts( @@ -296,9 +300,15 @@ Optional: upload car green card file in the same step. description: "Other parts and bank information saved successfully", type: SelectOtherPartsV2ResponseDto, }) - @ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" }) + @ApiResponse({ + status: 400, + description: "Invalid workflow step or validation failed", + }) @ApiResponse({ status: 404, description: "Claim case not found" }) - @ApiResponse({ status: 409, description: "Bank information already submitted" }) + @ApiResponse({ + status: 409, + description: "Bank information already submitted", + }) async selectOtherParts( @Param("claimRequestId") claimRequestId: string, @Body() body: SelectOtherPartsV2Dto, @@ -530,14 +540,22 @@ Returns status of each item (uploaded/captured or not). type: "object", required: ["sign", "agree", "branchId"], properties: { - sign: { type: "string", format: "binary", description: "Signature image" }, - agree: { type: "boolean", description: "true to accept, false to reject" }, + sign: { + type: "string", + format: "binary", + description: "Signature image", + }, + agree: { + type: "boolean", + description: "true to accept, false to reject", + }, branchId: { type: "string", description: "Insurer branch ID" }, }, }, }) @ApiOperation({ - summary: "Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)", + summary: + "Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)", description: "Field expert submits the damaged party's signature during the final approval stage. " + "Delegates to the same service method as the user sign endpoint; the expert's " + diff --git a/src/expert-claim/dto/claim-list-v2.dto.ts b/src/expert-claim/dto/claim-list-v2.dto.ts index 2c34c68..1d7ab5a 100644 --- a/src/expert-claim/dto/claim-list-v2.dto.ts +++ b/src/expert-claim/dto/claim-list-v2.dto.ts @@ -77,6 +77,19 @@ export class ClaimListItemV2Dto { example: true, }) requiresFileMakerApproval?: boolean; + + @ApiPropertyOptional({ + description: + "V5 only. Number of times the FileMaker has rejected this claim (0–2). When this reaches 2 the FileMaker can only approve.", + example: 1, + }) + fileMakerRejectionCount?: number; + + @ApiPropertyOptional({ + description: "V5 only. Reason text supplied with the most recent FileMaker rejection.", + example: "Damage assessment is incorrect — please re-evaluate parts X and Y", + }) + fileMakerRejectionReason?: string; } export class GetClaimListV2ResponseDto { diff --git a/src/expert-claim/exceptions/business-rule.exception.ts b/src/expert-claim/exceptions/business-rule.exception.ts index 97c7d7a..74e5e2a 100644 --- a/src/expert-claim/exceptions/business-rule.exception.ts +++ b/src/expert-claim/exceptions/business-rule.exception.ts @@ -22,4 +22,5 @@ export class BusinessRuleException extends HttpException { export const BusinessErrorCode = { DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED", + FILE_MAKER_REJECTION_LIMIT_EXCEEDED: "FILE_MAKER_REJECTION_LIMIT_EXCEEDED", } as const; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index ed231cb..6bd425a 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -4140,12 +4140,14 @@ export class ExpertClaimService { /** * Claim list for FILE_REVIEWER. * - * Visibility rules: + * Visibility rules (both V4 and V5 files): * - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either: * (a) not yet assigned to any reviewer, OR * (b) already assigned to THIS reviewer - * - WAITING_FOR_EXPERT files that are assigned to THIS reviewer - * (they completed the field work and the file is now in damage-expert queue) + * - Files already assigned to THIS reviewer (any post-assignment status) + * + * V4: isMadeByFileMaker=true, requiresFileMakerApproval=false + * V5: isMadeByFileMaker=true, requiresFileMakerApproval=true * * All scoped to this reviewer's insurance company via blame party clientId. */ @@ -4156,7 +4158,7 @@ export class ExpertClaimService { const clientKey = requireActorClientKey(actor); const reviewerOid = new Types.ObjectId(actor.sub); - // Find V4 blame files visible to this reviewer + // Find V4 and V5 blame files visible to this reviewer const blames = (await this.blameRequestDbService.find( { isMadeByFileMaker: true, @@ -4178,7 +4180,7 @@ export class ExpertClaimService { { lean: true, select: - "_id type parties blameStatus status expert.decision assignedFileReviewerId", + "_id type parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval", }, )) as any[]; @@ -4247,6 +4249,9 @@ export class ExpertClaimService { (!!blame?.isMadeByFileMaker && String(blame?.status ?? "") === "WAITING_FOR_EXPERT"), assignedToMe: !!assignedToMe, + requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval, + fileMakerRejectionCount: (c as any).fileMakerRejectionCount ?? 0, + fileMakerRejectionReason: (c as any).fileMakerRejectionReason ?? undefined, }; }) as ClaimListItemV2Dto[]; @@ -4254,8 +4259,9 @@ export class ExpertClaimService { } /** - * Claim list for FILE_MAKER — returns only the claims linked to blame files - * they personally created (V4 flow). + * Claim list for FILE_MAKER — returns claims linked to blame files they + * personally created (V4 and V5 flows). + * V4: requiresFileMakerApproval=false · V5: requiresFileMakerApproval=true */ private async getFileMakerClaimListV2( actor: any, @@ -4265,7 +4271,7 @@ export class ExpertClaimService { const makerBlames = (await this.blameRequestDbService.find( { isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid }, - { lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" }, + { lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId requiresFileMakerApproval" }, )) as any[]; if (makerBlames.length === 0) { @@ -4273,9 +4279,10 @@ export class ExpertClaimService { } const blameIds = makerBlames.map((b) => b._id); + // Include all claims linked to this maker's blames — both V4 (requiresFileMakerApproval=false) + // and V5 (requiresFileMakerApproval=true). const claims = (await this.claimCaseDbService.find({ blameRequestId: { $in: blameIds }, - requiresFileMakerApproval: true, })) as any[]; const blameById = new Map( @@ -4318,6 +4325,8 @@ export class ExpertClaimService { createdAt: c.createdAt, awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval, + fileMakerRejectionCount: (c as any).fileMakerRejectionCount ?? 0, + fileMakerRejectionReason: (c as any).fileMakerRejectionReason ?? undefined, needsFileReviewerCompletion: String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" || (!!blame?.isMadeByFileMaker && diff --git a/src/request-management/dto/run-inquiries-v3.dto.ts b/src/request-management/dto/run-inquiries-v3.dto.ts index 42d1a00..b165b37 100644 --- a/src/request-management/dto/run-inquiries-v3.dto.ts +++ b/src/request-management/dto/run-inquiries-v3.dto.ts @@ -4,6 +4,7 @@ import { IsNotEmpty, IsOptional, IsString, + MaxLength, ValidateNested, } from "class-validator"; import { Type } from "class-transformer"; @@ -89,3 +90,66 @@ export class RunInquiriesV3Dto { @IsString() insurerLicense?: string; } + +/** + * Body for `POST run-inquiries-vin/:requestId`. + * Identical to RunInquiriesV3Dto but uses `vin` (17-char chassis number) instead of `plate`. + * First call = guilty party (+ auto claim). Second call = damaged party (THIRD_PARTY only). + */ +export class RunInquiriesVinV3Dto { + @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: "IR123456789012345678901234", + description: + "Sheba (IBAN). Required on the damaged party call (CAR_BODY first call; THIRD_PARTY second call).", + }) + @IsOptional() + @IsString() + sheba?: string; + + @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/expert-initiated-blame-v3.mirror.controller.ts b/src/request-management/expert-initiated-blame-v3.mirror.controller.ts index c4d7728..59cf0c4 100644 --- a/src/request-management/expert-initiated-blame-v3.mirror.controller.ts +++ b/src/request-management/expert-initiated-blame-v3.mirror.controller.ts @@ -31,7 +31,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum"; import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; -import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; +import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto"; import { CarBodyFormDto, DescriptionDto, @@ -181,6 +181,23 @@ export class ExpertInitiatedBlameV3MirrorController { return this.requestManagementService.runInquiriesV3(requestId, expert, dto); } + @Post("run-inquiries-vin/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: RunInquiriesVinV3Dto }) + @ApiOperation({ + summary: "Run VIN/chassis inquiries for the current party (V3)", + description: + "VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " + + "1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).", + }) + async runInquiriesVin( + @CurrentUser() expert: any, + @Param("requestId") requestId: string, + @Body() dto: RunInquiriesVinV3Dto, + ) { + return this.requestManagementService.runInquiriesVinV3(requestId, expert, dto); + } + @Post("add-detail-location/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: LocationDto }) diff --git a/src/request-management/expert-initiated-blame.mirror.controller.ts b/src/request-management/expert-initiated-blame.mirror.controller.ts index c1e70b2..9b95886 100644 --- a/src/request-management/expert-initiated-blame.mirror.controller.ts +++ b/src/request-management/expert-initiated-blame.mirror.controller.ts @@ -27,6 +27,7 @@ import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; +import { InitialFormVinDto } from "./dto/create-request-management.dto"; import { BlameConfessionDtoV2, CarBodyFormDto, @@ -181,6 +182,29 @@ export class ExpertInitiatedBlameMirrorController { ); } + @Post("/run-inquiries-vin/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: InitialFormVinDto }) + @ApiOperation({ + summary: "[Expert mirror] Initial form via VIN/chassis inquiry for current party", + description: + "VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " + + "Fills the FIRST or SECOND party insurance/vehicle data depending on the current workflow step.", + }) + async initialFormVin( + @Param("requestId") requestId: string, + @Body() body: InitialFormVinDto, + @CurrentUser() expert: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.initialFormVinV2( + requestId, + body, + expert, + partyRole, + ); + } + @ApiBody({ schema: { type: "object", diff --git a/src/request-management/file-maker-blame-v4.controller.ts b/src/request-management/file-maker-blame-v4.controller.ts index 856881d..d3b7fa7 100644 --- a/src/request-management/file-maker-blame-v4.controller.ts +++ b/src/request-management/file-maker-blame-v4.controller.ts @@ -30,7 +30,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum"; import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; -import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; +import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto"; import { CarBodyFormDto, DescriptionDto, @@ -193,6 +193,27 @@ export class FileMakerBlameV4Controller { ); } + @Post("run-inquiries-vin/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: RunInquiriesVinV3Dto }) + @ApiOperation({ + summary: "Run VIN/chassis inquiries for the current party (V4)", + description: + "VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " + + "1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).", + }) + async runInquiriesVin( + @CurrentUser() fileMaker: any, + @Param("requestId") requestId: string, + @Body() dto: RunInquiriesVinV3Dto, + ) { + return this.requestManagementService.runInquiriesVinV3( + requestId, + fileMaker, + dto, + ); + } + // ─── Party details ──────────────────────────────────────────────────────────── @Post("add-detail-location/:requestId") diff --git a/src/request-management/file-maker-blame-v5.controller.ts b/src/request-management/file-maker-blame-v5.controller.ts index 3dffb2a..fcad7b4 100644 --- a/src/request-management/file-maker-blame-v5.controller.ts +++ b/src/request-management/file-maker-blame-v5.controller.ts @@ -30,7 +30,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum"; import { CreationMethod } from "./entities/schema/request-management.schema"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; -import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; +import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto"; import { CarBodyFormDto, DescriptionDto, @@ -192,6 +192,27 @@ export class FileMakerBlameV5Controller { ); } + @Post("run-inquiries-vin/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: RunInquiriesVinV3Dto }) + @ApiOperation({ + summary: "Run VIN/chassis inquiries for the current party (V5)", + description: + "VIN alternative to run-inquiries. Uses ESG chassis lookup instead of plate-based inquiry. " + + "1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).", + }) + async runInquiriesVin( + @CurrentUser() fileMaker: any, + @Param("requestId") requestId: string, + @Body() dto: RunInquiriesVinV3Dto, + ) { + return this.requestManagementService.runInquiriesVinV3( + requestId, + fileMaker, + dto, + ); + } + // ─── Party details ──────────────────────────────────────────────────────────── @Post("add-detail-location/:requestId") diff --git a/src/request-management/file-maker-claim-approval-v5.controller.ts b/src/request-management/file-maker-claim-approval-v5.controller.ts index 9183847..c482124 100644 --- a/src/request-management/file-maker-claim-approval-v5.controller.ts +++ b/src/request-management/file-maker-claim-approval-v5.controller.ts @@ -10,6 +10,7 @@ import { ApiBody, ApiOperation, ApiParam, + ApiResponse, ApiTags, } from "@nestjs/swagger"; import { IsOptional, IsString } from "class-validator"; @@ -91,9 +92,28 @@ export class FileMakerClaimApprovalV5Controller { @ApiOperation({ summary: "Reject the completed claim back to FileReviewer (FileMaker V5)", description: - "Rejects a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " + - "Moves claim to FILE_MAKER_REJECTED so the FileReviewer can re-lock, " + - "adjust the damage assessment, and restart user interaction as needed.", + "Rejects a claim that is in `WAITING_FOR_FILE_MAKER_APPROVAL` status. " + + "Moves the claim back to `WAITING_FOR_DAMAGE_EXPERT` and the linked blame back to " + + "`WAITING_FOR_FILE_REVIEWER` so the FileReviewer can re-lock, adjust the damage " + + "assessment, and restart user interaction as needed.\n\n" + + "**Rejection limit:** the FileMaker may reject at most **2 times** per claim. " + + "On the 3rd attempt the endpoint returns **422** with " + + "`errorCode: FILE_MAKER_REJECTION_LIMIT_EXCEEDED` — the FileMaker must approve instead. " + + "The current count is returned as `fileMakerRejectionCount` in the response and in " + + "`GET v2/expert-claim/requests`.", + }) + @ApiResponse({ + status: 422, + description: + "Business rule violation: rejection limit exceeded. " + + "`errorCode: FILE_MAKER_REJECTION_LIMIT_EXCEEDED` — the claim has already been rejected twice.", + schema: { + example: { + errorCode: "FILE_MAKER_REJECTION_LIMIT_EXCEEDED", + message: + "This claim has already been rejected twice. You must approve it now — no further rejections are allowed.", + }, + }, }) async reject( @Param("claimRequestId") claimRequestId: string, diff --git a/src/request-management/registrar-blame.mirror.controller.ts b/src/request-management/registrar-blame.mirror.controller.ts index f6a3150..02b7a4b 100644 --- a/src/request-management/registrar-blame.mirror.controller.ts +++ b/src/request-management/registrar-blame.mirror.controller.ts @@ -28,6 +28,7 @@ import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; +import { InitialFormVinDto } from "./dto/create-request-management.dto"; import { CarBodyFormDto, DescriptionDto, @@ -165,6 +166,29 @@ export class RegistrarBlameMirrorController { ); } + @Post("/initial-form-vin/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: InitialFormVinDto }) + @ApiOperation({ + summary: "[Registrar mirror] Initial form via VIN/chassis inquiry for current party", + description: + "VIN alternative to initial-form. Uses ESG chassis lookup instead of plate-based inquiry. " + + "Fills the FIRST or SECOND party insurance/vehicle data depending on the current workflow step.", + }) + async initialFormVin( + @Param("requestId") requestId: string, + @Body() body: InitialFormVinDto, + @CurrentUser() registrar: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.initialFormVinV2( + requestId, + body, + registrar, + partyRole, + ); + } + @ApiBody({ schema: { type: "object", diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 734f8c5..20b6175 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -42,7 +42,7 @@ import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatu import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { resolveClaimOwnerFieldsFromBlame } from "src/helpers/blame-damaged-party"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; -import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto"; +import { RunInquiriesV3Dto, RunInquiriesVinV3Dto } from "./dto/run-inquiries-v3.dto"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -84,6 +84,7 @@ import { WorkflowStepDbService } from "src/workflow-step-management/entities/db- import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema"; import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { BusinessRuleException, BusinessErrorCode } from "src/expert-claim/exceptions/business-rule.exception"; import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service"; import { ExpertFileActivityType, @@ -9153,6 +9154,399 @@ export class RequestManagementService { }; } + /** + * VIN variant of `runInquiriesV3`. + * Identical flow, but calls `getPolicyByChassisInquiry` instead of + * `getTejaratBlockInquiry` for the primary policy lookup. + */ + async runInquiriesVinV3( + requestId: string, + actor: any, + dto: RunInquiriesVinV3Dto, + ): Promise<{ + blameRequestId: string; + partyRole: PartyRole; + claimRequestId?: string; + claimPublicId?: string; + message: string; + }> { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Blame request not found"); + this.assertBlameV3ExpertInPerson(req); + await this.verifyExpertAccessForBlameV2(req, actor); + + const partyRole = this.resolveV3InquiryPartyRole(req); + if (!partyRole) { + throw new ConflictException("All party inquiries are already complete."); + } + + if (partyRole === PartyRole.FIRST) { + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstIdx === -1) + throw new BadRequestException("First party not found"); + const firstParty = req.parties[firstIdx]; + if (!firstParty?.person?.userId) { + throw new BadRequestException( + "Guilty party OTP must be verified before running inquiries.", + ); + } + if (req.type === BlameRequestType.CAR_BODY) { + const form = firstParty.carBodyFirstForm as any; + if (form?.car == null && form?.object == null) { + throw new BadRequestException( + "Submit car-body-form before running inquiries (CAR_BODY).", + ); + } + } + + await this.runPartyInquiriesVinV3Internal( + req, + dto, + PartyRole.FIRST, + firstParty, + ); + if (req.type === BlameRequestType.CAR_BODY) { + await this.validateShebaV3( + dto as any, + firstParty.person?.clientId?.toString(), + ); + } + + this.markPartyInquiriesCompleteOnBlame(req, PartyRole.FIRST, actor); + await (req as any).save(); + + const nationalCode = + dto.nationalCodeOfInsurer || dto.nationalCodeOfDriver || ""; + const newClaim = await this.createV3ClaimFromBlame(req, actor, { + sheba: req.type === BlameRequestType.CAR_BODY ? dto.sheba : undefined, + nationalCode: + req.type === BlameRequestType.CAR_BODY ? nationalCode : undefined, + interimThirdParty: req.type === BlameRequestType.THIRD_PARTY, + }); + + return { + blameRequestId: requestId, + partyRole: PartyRole.FIRST, + claimRequestId: String(newClaim._id), + claimPublicId: req.publicId, + message: + "Guilty-party inquiries complete and claim created. Proceed with location, description, voice, and signature.", + }; + } + + // Damaged party (THIRD_PARTY only) + if (!this.partyHasSigned(req, PartyRole.FIRST)) { + throw new BadRequestException( + "Guilty party must sign before running damaged-party inquiries.", + ); + } + const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + if (secondIdx === -1 || !req.parties[secondIdx]?.person?.userId) { + throw new BadRequestException( + "Damaged party OTP must be verified before running inquiries.", + ); + } + + const secondParty = req.parties[secondIdx]; + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const firstUserId = + firstIdx !== -1 ? req.parties[firstIdx]?.person?.userId : null; + const secondUserId = secondParty.person?.userId; + if ( + firstUserId && + secondUserId && + String(firstUserId) === String(secondUserId) + ) { + throw new BadRequestException( + "Guilty and damaged parties are bound to the same user account. " + + "Re-verify the second party OTP using a different phone number.", + ); + } + + await this.runPartyInquiriesVinV3Internal( + req, + dto, + PartyRole.SECOND, + secondParty, + ); + await this.validateShebaV3( + dto as any, + secondParty.person?.clientId?.toString(), + ); + + this.ensureV3GuiltyPartyDecision(req); + if (typeof (req as any).markModified === "function") { + (req as any).markModified("parties"); + } + await (req as any).save(); + + await this.syncV3ClaimDamagedPartyFromBlame(req, dto as any); + + this.markPartyInquiriesCompleteOnBlame(req, PartyRole.SECOND, actor); + await (req as any).save(); + + return { + blameRequestId: requestId, + partyRole: PartyRole.SECOND, + message: + "Damaged-party inquiries complete. Proceed with location, description, voice, and signature.", + }; + } + + /** + * VIN variant of `runPartyInquiriesV3Internal`. + * Calls `getPolicyByChassisInquiry` (ESG chassis lookup) instead of + * `getTejaratBlockInquiry` (plate-based). All other inquiries (personal, + * driving licence, car-body for CAR_BODY) are unchanged. + */ + private async runPartyInquiriesVinV3Internal( + req: any, + partyData: RunInquiriesVinV3Dto, + partyRole: PartyRole, + party: any, + ): Promise<{ clientId?: string }> { + const roleLabel = partyRole === PartyRole.FIRST ? "FIRST" : "SECOND"; + let clientId: string | undefined; + const inquiryOptions = (cid?: string) => + cid ? { clientId: cid } : undefined; + + let inquiryRaw: any; + let inquiryMapped: any; + try { + const inquiry = await this.sandHubService.getPolicyByChassisInquiry( + partyData.vin, + inquiryOptions(party?.person?.clientId?.toString()), + ); + inquiryRaw = inquiry.raw; + inquiryMapped = inquiry.mapped; + this.recordPartyCaseInquiryStatus(req, "thirdParty", partyRole, true, { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); + } catch (err: any) { + this.logger.error( + `[V3-VIN] vin inquiry failed for ${roleLabel} party (request=${req._id}): ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + partyRole, + false, + {}, + err, + ); + throw new BadRequestException( + `${roleLabel} party VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + + if (inquiryMapped?.Error) { + throw new BadRequestException( + inquiryMapped.Error.Message || + `${roleLabel} party VIN inquiry returned an error`, + ); + } + + const clientName = inquiryMapped?.CompanyName; + const companyCode = inquiryMapped?.CompanyCode; + if (!clientName) { + throw new BadRequestException( + `CompanyName missing from ${roleLabel} party VIN inquiry response`, + ); + } + const client = await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ); + if (!client) { + throw new BadRequestException( + `CompanyCode missing or invalid in ${roleLabel} party VIN inquiry response`, + ); + } + const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; + clientId = resolvedClientId ? String(resolvedClientId) : undefined; + + if (!party.person) party.person = {} as any; + party.person.clientId = resolvedClientId; + party.person.nationalCodeOfInsurer = partyData.nationalCodeOfInsurer; + party.person.nationalCodeOfDriver = partyData.nationalCodeOfDriver; + party.person.driverIsInsurer = partyData.driverIsInsurer; + party.person.insurerBirthday = partyData.insurerBirthday; + party.person.driverBirthday = + partyData.driverBirthday ?? + (partyData.driverIsInsurer ? String(partyData.insurerBirthday) : null); + if (partyData.insurerLicense) + party.person.insurerLicense = partyData.insurerLicense; + if (partyData.driverLicense) + party.person.driverLicense = partyData.driverLicense; + + if (!party.vehicle) party.vehicle = {} as any; + party.vehicle.plateId = partyData.vin; // store VIN as vehicle identifier + party.vehicle.name = inquiryMapped?.MapTypNam; + party.vehicle.type = `${inquiryMapped?.UsageField ?? ""} / ${inquiryMapped?.UsageName ?? inquiryMapped?.MapUsageName ?? "-"}`; + party.vehicle.inquiry = { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }; + + if (!party.insurance) party.insurance = {} as any; + party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; + party.insurance.company = clientName; + party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; + party.insurance.startDate = inquiryMapped?.IssueDate; + party.insurance.endDate = inquiryMapped?.EndDate; + + if ( + req.type === BlameRequestType.CAR_BODY && + partyRole === PartyRole.FIRST + ) { + try { + const carBodyInfo = await this.sandHubService.getTejaratCarBodyInquiry( + { + nationalCodeOfInsurer: partyData.nationalCodeOfInsurer, + plate: partyData.vin as any, // VIN used as identifier for CAR_BODY + }, + clientId ? { clientId } : undefined, + ); + this.recordPartyCaseInquiryStatus(req, "carBody", partyRole, true, { + source: "TEJARAT_CAR_BODY_VIN_INQUIRY", + raw: carBodyInfo.raw, + mapped: carBodyInfo.mapped, + }); + party.vehicle.inquiry = { + ...party.vehicle.inquiry, + carBody: { + source: "TEJARAT_CAR_BODY_VIN_INQUIRY", + raw: carBodyInfo.raw, + mapped: carBodyInfo.mapped, + }, + }; + const m = carBodyInfo.mapped; + (party.insurance as any).carBodyInsurance = { + policyNumber: m.policyNumber ?? null, + companyId: m.companyId ?? null, + companyName: m.CompanyName ?? null, + }; + const cbCompanyCode = m.companyId ?? m.CompanyCode; + const cbCompanyName = m.CompanyName ?? m.companyPersianName; + if (cbCompanyCode && cbCompanyName) { + const cbClient = + await this.clientService.findOrCreateClientByCompanyCode( + Number(cbCompanyCode), + String(cbCompanyName), + ); + const cbClientId = + (cbClient as any)?._id ?? (cbClient as any)?._doc?._id; + if (cbClientId) { + party.person.clientId = cbClientId; + clientId = String(cbClientId); + } + } + } catch (err: any) { + this.logger.error( + `[V3-VIN] car body inquiry failed for request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus( + req, + "carBody", + partyRole, + false, + {}, + err, + ); + throw new BadRequestException( + `CAR_BODY VIN inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + const nationalCode = + partyData.nationalCodeOfInsurer || partyData.nationalCodeOfDriver; + const birthDate = partyData.insurerBirthday ?? partyData.driverBirthday; + if (nationalCode && birthDate != null) { + try { + const personalInquiry = await this.sandHubService.getPersonalInquiry( + nationalCode, + birthDate as string | number, + clientId ? { clientId } : undefined, + ); + this.recordPartyCaseInquiryStatus( + req, + "person", + partyRole, + true, + personalInquiry, + ); + } catch (err: any) { + this.logger.error( + `[V3-VIN] personal inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus( + req, + "person", + partyRole, + false, + {}, + err, + ); + throw new BadRequestException( + `${roleLabel} party personal identity inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + const licenseNationalCode = partyData.driverIsInsurer + ? partyData.nationalCodeOfInsurer + : partyData.nationalCodeOfDriver; + const licenseNumber = partyData.driverIsInsurer + ? partyData.insurerLicense + : partyData.driverLicense; + + if (!licenseNumber) { + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + false, + { skipped: true, reason: "No license number provided" }, + ); + } else { + try { + const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo( + licenseNationalCode, + licenseNumber, + clientId ? { clientId } : undefined, + ); + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + true, + licenseInquiry, + ); + } catch (err: any) { + this.logger.error( + `[V3-VIN] driving license inquiry failed for ${roleLabel} party request=${req._id}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus( + req, + "drivingLicence", + partyRole, + false, + {}, + err, + ); + throw new BadRequestException( + `${roleLabel} party driving license inquiry failed: ${err?.message || "استعلام در دسترس نیست"}`, + ); + } + } + + return { clientId }; + } + private assertBlameV3PartyDetailPhase(req: any, partyRole: PartyRole): void { this.assertBlameV3ExpertInPerson(req); if (partyRole === PartyRole.FIRST) { @@ -9967,7 +10361,17 @@ export class RequestManagementService { this.assertFileMakerApprovalAccess(claim, fileMaker); - if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) { + // After 2 rejections the claim is in WAITING_FOR_DAMAGE_EXPERT (forced-approve + // path); any other status is still an error. + const rejectionCount = (claim as any).fileMakerRejectionCount ?? 0; + const forcedApprove = + rejectionCount >= 2 && + claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + + if ( + claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL && + !forcedApprove + ) { throw new BadRequestException( `Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to approve. Current: ${claim.status}`, ); @@ -10011,11 +10415,14 @@ export class RequestManagementService { } /** - * V5: FileMaker rejects the completed claim back to expert review. + * V5: FileMaker rejects the completed claim back to FileReviewer for correction. * - * Moves claim from WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT - * so the FileReviewer can re-lock it and adjust the damage assessment or - * restart the back-and-forth with the user. + * Allowed at most 2 times per claim lifetime. On the 3rd attempt the endpoint + * returns 422 FILE_MAKER_REJECTION_LIMIT_EXCEEDED and the FileMaker must approve. + * + * Transition (both blame + claim are updated atomically): + * claim: WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT + * blame: (any status) → WAITING_FOR_FILE_REVIEWER */ async fileMakerRejectV5( fileMaker: any, @@ -10025,6 +10432,7 @@ export class RequestManagementService { claimRequestId: string; publicId: string; status: string; + fileMakerRejectionCount: number; message: string; }> { const claim = await this.claimCaseDbService.findById(claimRequestId); @@ -10038,14 +10446,26 @@ export class RequestManagementService { ); } + const currentRejections = (claim as any).fileMakerRejectionCount ?? 0; + if (currentRejections >= 2) { + throw new BusinessRuleException( + BusinessErrorCode.FILE_MAKER_REJECTION_LIMIT_EXCEEDED, + "This claim has already been rejected twice. You must approve it now — no further rejections are allowed.", + ); + } + + const newRejectionCount = currentRejections + 1; const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim(); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { - status: ClaimCaseStatus.FILE_MAKER_REJECTED, + status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: ClaimStatus.NEEDS_REVISION, + fileMakerRejectionCount: newRejectionCount, + fileMakerRejectionReason: reason ?? null, "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + "workflow.locked": false, }, $push: { history: { @@ -10056,17 +10476,43 @@ export class RequestManagementService { actorType: "file_maker", }, timestamp: new Date(), - metadata: { reason: reason ?? null }, + metadata: { reason: reason ?? null, rejectionCount: newRejectionCount }, }, }, }); + // Reset blame back to WAITING_FOR_FILE_REVIEWER so the reviewer's list + // query (which looks for that status + assignedFileReviewerId) surfaces it. + if (claim.blameRequestId) { + await this.blameRequestDbService.findByIdAndUpdate( + String(claim.blameRequestId), + { + $set: { status: CaseStatus.WAITING_FOR_FILE_REVIEWER }, + $push: { + history: { + type: "V5_FILE_MAKER_REJECTED_BLAME_RESET", + actor: { + actorId: new Types.ObjectId(fileMaker.sub), + actorName, + actorType: "file_maker", + }, + timestamp: new Date(), + metadata: { claimRequestId, reason: reason ?? null }, + }, + }, + }, + ); + } + return { claimRequestId, publicId: claim.publicId, - status: ClaimCaseStatus.FILE_MAKER_REJECTED, + status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, + fileMakerRejectionCount: newRejectionCount, message: - "Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.", + newRejectionCount >= 2 + ? "Claim rejected by FileMaker (2nd rejection — next action must be approval). FileReviewer can re-lock and adjust the assessment." + : "Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.", }; }