diff --git a/src/auth/auth-services/user.auth.service.ts b/src/auth/auth-services/user.auth.service.ts index 3d1ba7f..0805592 100644 --- a/src/auth/auth-services/user.auth.service.ts +++ b/src/auth/auth-services/user.auth.service.ts @@ -77,7 +77,7 @@ export class UserAuthService { role: "user", }; const accToken = this.jwtService.sign(payload, { - secret: `${process.env.JWT_SECRET}`, + secret: `${process.env.JWT_SECRET}`, expiresIn: '1h' }); await this.userDbService.findOneAndUpdate( { username: user.username }, diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 83e8cf2..817edf9 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -138,6 +138,25 @@ export class ClaimDetailV2ResponseDto { }) requiresFileMakerApproval?: boolean; + @ApiPropertyOptional({ + description: + "V5 only. Number of times the FileMaker has rejected this claim (0–2). When 2, the next action must be approval.", + example: 1, + }) + fileMakerRejectionCount?: number; + + @ApiPropertyOptional({ + description: "V5 only. Reason text from the most recent FileMaker rejection.", + example: "قیمت نامعقول", + }) + fileMakerRejectionReason?: string; + + @ApiPropertyOptional({ + description: "V5 only. ObjectId of the FileMaker who must approve (or has approved) this claim.", + example: "6a43a2689faee3d20bf24030", + }) + fileMakerApprovalActorId?: string; + @ApiPropertyOptional({ description: "Slice of `claim.evaluation` exposed to the damage expert. " + diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 6bd425a..e27b3ca 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -971,7 +971,7 @@ export class ExpertClaimService { daghi: { option: part.daghi.option, ...(part.daghi.price && { price: part.daghi.price }), - ...(part.daghi.branchId && { + ...(part.daghi.branchId && Types.ObjectId.isValid(part.daghi.branchId) && { branchId: new Types.ObjectId(part.daghi.branchId), }), }, @@ -2671,32 +2671,39 @@ export class ExpertClaimService { if (!reviewerBlame) { throw new NotFoundException("Linked blame file not found."); } - const blameStatus = (reviewerBlame as any).status as string; - if (blameStatus === "WAITING_FOR_FILE_REVIEWER") { - // Phase 1: blame-assign path - return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); - } - // Phase 2: blame is past the initial assignment step. - // Only the reviewer who was assigned during Phase 1 may proceed. const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId ? String((reviewerBlame as any).assignedFileReviewerId) : null; - if (!assignedReviewerId) { - throw new BadRequestException({ - success: false, - status: "unavailable" satisfies ExpertFileAssignStatus, - message: "No reviewer has been assigned to this file yet.", - }); + const blameStatus = (reviewerBlame as any).status as string; + + if (blameStatus === "WAITING_FOR_FILE_REVIEWER") { + if (assignedReviewerId && assignedReviewerId === actor.sub) { + // Reviewer already assigned (e.g. after a FileMaker rejection that reset + // blame back to WAITING_FOR_FILE_REVIEWER) — skip Phase 1 and fall + // through to the damage-expert workflow lock below. + } else { + // Phase 1: first-time blame assignment + return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); + } + } else { + // Phase 2: blame is past the initial assignment step. + // Only the reviewer who was assigned during Phase 1 may proceed. + if (!assignedReviewerId) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "No reviewer has been assigned to this file yet.", + }); + } + if (assignedReviewerId !== actor.sub) { + throw new ConflictException({ + success: false, + status: "locked" satisfies ExpertFileAssignStatus, + message: "This file is assigned to another reviewer.", + }); + } } - if (assignedReviewerId !== actor.sub) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "This file is assigned to another reviewer.", - }); - } - // Assigned reviewer — fall through to the damage-expert workflow lock below. - // (actor.role stays FILE_REVIEWER; assertExpertActorOnClaim checks clientKey scope) + // Fall through to the damage-expert workflow lock below. } await this.assertExpertActorOnClaim(claim, actor); @@ -2777,10 +2784,16 @@ export class ExpertClaimService { const now = new Date(); const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const expiredAt = new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS); - const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert"; + const actorType = isFieldExpertOwner + ? "field_expert" + : (actor as any).role === RoleEnum.FILE_REVIEWER + ? "file_reviewer" + : "damage_expert"; const actorRoleLabel = isFieldExpertOwner ? ("field_expert" as const) - : ("damage_expert" as const); + : (actor as any).role === RoleEnum.FILE_REVIEWER + ? ("file_reviewer" as const) + : ("damage_expert" as const); const lockedByPayload = { actorId: expertOid, @@ -2834,7 +2847,13 @@ export class ExpertClaimService { }, }; - if (!claim.workflow?.assignedForReviewBy) { + // Update assignedForReviewBy whenever a FILE_REVIEWER re-locks after a + // FileMaker rejection — the stale payload (from a prior damage-expert cycle) + // must be replaced with the actual reviewer so submit checks pass. + if ( + !claim.workflow?.assignedForReviewBy || + (actor as any).role === RoleEnum.FILE_REVIEWER + ) { baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; } @@ -3363,6 +3382,12 @@ export class ExpertClaimService { const nextCaseStatus = claimCaseStatusAfterExpertReplyV2(processedParts); let nextClaimStatus = ClaimStatus.APPROVED; + // V5 flow: FileMaker must approve before the owner is asked to sign. + // When no factor upload is needed, skip the owner-sign step entirely here + // and park the claim at WAITING_FOR_FILE_MAKER_APPROVAL. The owner SMS + // and INSURER_REVIEW status are emitted by fileMakerApproveV5 instead. + const isV5Claim = !!(claim as any).requiresFileMakerApproval; + if (needsFactorUpload) { nextClaimStatus = ClaimStatus.NEEDS_REVISION; if (mixedFactorAndPrice) { @@ -3372,6 +3397,10 @@ export class ExpertClaimService { currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS; nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION; } + } else if (isV5Claim) { + // V5, no factors: hold at WAITING_FOR_FILE_MAKER_APPROVAL + currentStep = ClaimWorkflowStep.INSURER_REVIEW; + nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED; } const mergedSelectedParts = [...ownerSelectedParts]; @@ -3389,7 +3418,9 @@ export class ExpertClaimService { } const updatePayload: Record = { - status: nextCaseStatus, + status: isV5Claim && !needsFactorUpload + ? ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL + : nextCaseStatus, claimStatus: nextClaimStatus, ...(expertAddedParts.length > 0 ? { "damage.selectedParts": mergedSelectedParts } @@ -3445,8 +3476,9 @@ export class ExpertClaimService { idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`, }); + // V5: owner SMS is sent by fileMakerApproveV5 after FileMaker approval. const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim); - if (ownerPhoneNotify && !needsFactorUpload) { + if (ownerPhoneNotify && !needsFactorUpload && !isV5Claim) { const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; await this.smsOrchestrationService.sendSignatureReviewNotice({ @@ -3454,7 +3486,7 @@ export class ExpertClaimService { fileKind: "claim", publicId: claim.publicId, expertLastName, - link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"), + link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v2"), }); } @@ -5073,6 +5105,11 @@ export class ExpertClaimService { damagedParts, awaitingFactorValidation: isFactorValidationPending, requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval, + fileMakerRejectionCount: (claim as any).fileMakerRejectionCount ?? 0, + fileMakerRejectionReason: (claim as any).fileMakerRejectionReason ?? undefined, + fileMakerApprovalActorId: (claim as any).fileMakerApprovalActorId + ? String((claim as any).fileMakerApprovalActorId) + : undefined, evaluation: (evaluationForApi as | ClaimDetailV2ResponseDto["evaluation"] 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 c482124..0ef7882 100644 --- a/src/request-management/file-maker-claim-approval-v5.controller.ts +++ b/src/request-management/file-maker-claim-approval-v5.controller.ts @@ -64,26 +64,19 @@ export class FileMakerClaimApprovalV5Controller { @ApiOperation({ summary: "Approve the completed claim (FileMaker V5)", description: - "Approves a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " + - "Marks the claim COMPLETED and triggers fanavaran submission.", + "Approves a claim that is in `WAITING_FOR_FILE_MAKER_APPROVAL` status. " + + "Moves the claim to `INSURER_REVIEW_AWAITING_OWNER_SIGN` and sends the owner an SMS " + + "with a signature link. Fanavaran submission is triggered automatically after the " + + "owner signs.", }) async approve( @Param("claimRequestId") claimRequestId: string, @CurrentUser() fileMaker: any, ) { - const result = await this.requestManagementService.fileMakerApproveV5( + return this.requestManagementService.fileMakerApproveV5( fileMaker, claimRequestId, ); - - // Trigger fanavaran auto-submit now that the claim is COMPLETED and the gate - // (requiresFileMakerApproval) has been cleared. - const fanavaran = - await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted( - claimRequestId, - ); - - return { ...result, fanavaran }; } @Post("reject/:claimRequestId") diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 20b6175..46b4b2d 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -10389,28 +10389,53 @@ export class RequestManagementService { metadata: {}, }; - // Move claim to COMPLETED and clear the approval gate + // V5 approval: move claim to INSURER_REVIEW_AWAITING_OWNER_SIGN so the + // owner can now sign. Clear requiresFileMakerApproval so that when the + // owner later signs and autoSubmitToFanavaranV2OnClaimCompleted runs, it + // does not re-intercept the claim as a pending V5 gate. await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { - status: ClaimCaseStatus.COMPLETED, + status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, claimStatus: ClaimStatus.APPROVED, requiresFileMakerApproval: false, - "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, + "workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, $push: { - "workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW, history: historyEntry, }, }); + // Notify the owner that the claim is ready for their signature. + const notifyUserId = (claim as any).damagedPartyUserId ?? (claim as any).owner?.userId; + if (notifyUserId && claim.blameRequestId) { + const blame = await this.blameRequestDbService.findById( + String(claim.blameRequestId), + ); + const ownerParty = (blame?.parties || []).find( + (p: any) => + p?.person?.userId && String(p.person.userId) === String(notifyUserId), + ); + const ownerPhone = ownerParty?.person?.phoneNumber + ?? (await this.userDbService.findOne({ _id: new Types.ObjectId(String(notifyUserId)) }))?.mobile; + if (ownerPhone && typeof ownerPhone === "string") { + await this.smsOrchestrationService.sendSignatureReviewNotice({ + receptor: ownerPhone, + fileKind: "claim", + publicId: claim.publicId, + expertLastName: actorName.split(/\s+/).pop() || "کارشناس", + link: this.smsOrchestrationService.buildClaimLink(claimRequestId, "v2"), + }); + } + } + return { claimRequestId, publicId: claim.publicId, - status: ClaimCaseStatus.COMPLETED, + status: ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN, message: - "Claim approved by FileMaker. Claim is now marked COMPLETED. " + - "Proceed with fanavaran submission via the claim service.", + "Claim approved by FileMaker. Owner has been notified to sign. " + + "Once the owner signs, the claim will be completed and submitted to Fanavaran.", }; } @@ -10467,6 +10492,25 @@ export class RequestManagementService { "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, "workflow.locked": false, }, + // Clear all stale lock fields and prior-cycle evaluation data so the + // FileReviewer starts a completely clean lock cycle: + // - workflow lock fields left over from the completed review cycle + // - damageExpertReply / damageExpertResend / ownerInsurerApproval / + // ownerPricedPartsApproval — all belong to the rejected cycle and + // must not bleed into the new one (resend fulfilledAt would block + // submitResendDocsV2; stale reply would mislead the frontend) + // - assignedForReviewBy is intentionally kept so the same reviewer + // is still scoped to this file on re-lock + $unset: { + "workflow.lockedAt": "", + "workflow.expiredAt": "", + "workflow.lockedBy": "", + "workflow.preLockQueueSnapshot": "", + "evaluation.damageExpertReply": "", + "evaluation.damageExpertResend": "", + "evaluation.ownerInsurerApproval": "", + "evaluation.ownerPricedPartsApproval": "", + }, $push: { history: { type: "V5_FILE_MAKER_REJECTED", @@ -10623,7 +10667,7 @@ export class RequestManagementService { agent: any, requestId: string, dto: Omit, - ): Promise<{ blameRequestId: string; message: string }> { + ): Promise> { if (agent?.role !== RoleEnum.CALL_CENTER) { throw new ForbiddenException("Only call-center agents can use this endpoint."); } @@ -10651,9 +10695,41 @@ export class RequestManagementService { }); await (req as any).save(); + // Return the inquiry-populated data so the agent can review before sending the link. + const firstPartyAfter = req.parties[firstIdx]; return { blameRequestId: requestId, + publicId: (req as any).publicId, + type: (req as any).type, + status: (req as any).status, message: "Guilty-party inquiry complete. You can now send the blame link to the user.", + guiltyParty: { + vehicle: firstPartyAfter?.vehicle + ? { + plateId: firstPartyAfter.vehicle.plateId, + 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: firstPartyAfter.person.insurerBirthday, + driverBirthday: firstPartyAfter.person.driverBirthday, + } + : undefined, + }, }; } @@ -10734,14 +10810,24 @@ export class RequestManagementService { type: (req as any).type, status: (req as any).status, blameStatus: (req as any).blameStatus, - workflow: (req as any).workflow, + workflow: { + currentStep: (req as any).workflow?.currentStep, + nextStep: (req as any).workflow?.nextStep, + completedSteps: (req as any).workflow?.completedSteps, + }, skipInitialFormStep: (req as any).skipInitialFormStep, parties: ((req as any).parties ?? []).map((p: any) => ({ role: p.role, person: { phoneNumber: p.person?.phoneNumber, nationalCodeOfInsurer: p.person?.nationalCodeOfInsurer, - clientId: p.person?.clientId, + nationalCodeOfDriver: p.person?.nationalCodeOfDriver, + driverIsInsurer: p.person?.driverIsInsurer, + insurerBirthday: p.person?.insurerBirthday, + driverBirthday: p.person?.driverBirthday, + clientId: p.person?.clientId + ? String(p.person.clientId) + : undefined, }, insurance: p.insurance ? { @@ -10749,13 +10835,20 @@ export class RequestManagementService { policyNumber: p.insurance.policyNumber, startDate: p.insurance.startDate, endDate: p.insurance.endDate, + financialCeiling: p.insurance.financialCeiling, } : undefined, vehicle: p.vehicle - ? { plateId: p.vehicle.plateId, name: p.vehicle.name } + ? { + plateId: p.vehicle.plateId, + name: p.vehicle.name, + type: p.vehicle.type, + } : undefined, + inquiryComplete: !!(p.person?.inquiriesCompleted), })), createdAt: (req as any).createdAt, + updatedAt: (req as any).updatedAt, }; }