diff --git a/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts index bab1390..609a562 100644 --- a/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts +++ b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts @@ -24,7 +24,10 @@ export enum ClaimWorkflowStep { /** After user objection: damage expert’s last priced reply (stored in evaluation.damageExpertReplyFinal) */ EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY", EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION", - + + /** Owner must upload repair factor files for factorNeeded lines (before expert COST_EVALUATION). */ + OWNER_UPLOAD_FACTOR_DOCUMENTS = "OWNER_UPLOAD_FACTOR_DOCUMENTS", + // Insurer approval INSURER_REVIEW = "INSURER_REVIEW", diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index bbe8aeb..418d346 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -84,6 +84,12 @@ import { normalizeResendPartKeys, partKeyAllowedForExpertResend, } from "src/helpers/claim-expert-resend"; +import { + classifyV2ExpertPricingParts, + getActiveV2ExpertReply, + objectionDisallowedDueToOutstandingFactorWorkflow, +} from "src/helpers/claim-v2-expert-reply-workflow"; +import { buildClaimDetailsV2OwnerGuidance } from "src/helpers/claim-details-v2-owner-guidance"; import { canonicalizeClaimCarAngleKey, getClaimCarAngleCaptureBlob, @@ -2363,9 +2369,30 @@ export class ClaimRequestManagementService { ); } + if (claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) { + throw new BadRequestException( + "Factors can only be uploaded while the insurer-approval phase is pending.", + ); + } if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) { throw new BadRequestException( - "Factors can only be uploaded while the claim is in NEEDS_REVISION (expert requested factor documents). After all factors are uploaded the claim moves to UNDER_REVIEW for expert validation only.", + "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.", ); } @@ -2459,7 +2486,11 @@ export class ClaimRequestManagementService { ); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { - $set: { claimStatus: ClaimStatus.UNDER_REVIEW }, + $set: { + claimStatus: ClaimStatus.UNDER_REVIEW, + "workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION, + "workflow.nextStep": ClaimWorkflowStep.INSURER_REVIEW, + }, $push: { history: { type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION", @@ -5033,8 +5064,10 @@ export class ClaimRequestManagementService { /** * V2 API: User objection (ClaimCase + evaluation.objection payload). * - * Preconditions: damage expert resend and/or first expert reply exists; no prior objection; no final reply yet. - * Post: claim returns to expert queue (WAITING_FOR_DAMAGE_EXPERT), objection stored, optional new parts merged into damage.selectedParts. + * Priced-flow: 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, @@ -5058,26 +5091,26 @@ export class ClaimRequestManagementService { throw new ForbiddenException('Only the claim owner can submit an objection'); } - const hasExpertResend = this.claimCaseHasExpertResend(claimCase); - const hasFirstExpertReply = !!claimCase.evaluation?.damageExpertReply; - const hasFinalExpertReply = !!claimCase.evaluation?.damageExpertReplyFinal; - - if (!hasExpertResend && !hasFirstExpertReply) { - throw new BadRequestException( - 'You can only object after the damage expert has submitted an assessment or requested a resend.', - ); - } - - if (hasFinalExpertReply) { - throw new ConflictException( - 'The expert has already submitted the final reply after objection; you cannot submit another objection through this endpoint.', - ); - } - 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 }, + ); + const partsIn = body.objectionParts ?? []; const newPartsIn = body.newParts ?? []; if (partsIn.length === 0 && newPartsIn.length === 0) { @@ -5086,6 +5119,61 @@ export class ClaimRequestManagementService { ); } + let pricingObjectionEligible = false; + if ( + claimCase.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && + 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 pricedPartIdSet = + activeExpert?.parts?.length ? + new Set( + activeExpert.parts + .filter((p) => p.factorNeeded !== true) + .map((p) => String((p as { partId?: string }).partId ?? "")) + .filter(Boolean), + ) + : new Set(); + + if (partsIn.length && pricingObjectionEligible) { + for (const op of partsIn) { + if (!pricedPartIdSet.has(String(op.partId))) { + throw new BadRequestException( + `Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`, + ); + } + } + } + const objectionParts = partsIn.map((p) => ({ partId: p.partId, reason: p.reason, @@ -5126,6 +5214,10 @@ export class ClaimRequestManagementService { 'evaluation.objection': objectionPayload, 'damage.selectedParts': mergedSelected, }, + $unset: { + 'evaluation.ownerPricedPartsApproval': "", + 'evaluation.ownerInsurerApproval': "", + }, $push: { history: { type: 'USER_OBJECTION_SUBMITTED', @@ -5227,9 +5319,13 @@ export class ClaimRequestManagementService { } /** - * V2: Claim owner signs final priced expert reply (single-party), same intent as blame - * `PUT …/sign/:requestId` — only when case status is WAITING_FOR_INSURER_APPROVAL and - * workflow is at INSURER_REVIEW (not factor-validation queue at EXPERT_COST_EVALUATION). + * V2: Claim owner signature on expert pricing. + * + * Phase 1 (mixed priced + factor-needed lines): 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): WAITING_FOR_INSURER_APPROVAL, APPROVED, INSURER_REVIEW — full accept/reject completes the case. */ async submitOwnerInsurerApprovalSignV2( claimRequestId: string, @@ -5245,6 +5341,7 @@ export class ClaimRequestManagementService { claimStatus: ClaimStatus; currentStep?: ClaimWorkflowStep; accepted: boolean; + phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL"; }> { if (!Types.ObjectId.isValid(claimRequestId)) { throw new BadRequestException("Invalid claim request id"); @@ -5271,7 +5368,7 @@ export class ClaimRequestManagementService { if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) { throw new BadRequestException( - `Claim is not waiting for your approval signature. Current status: ${String(claimCase.status)}`, + `Claim is not waiting for your insurer-approval signature. Current status: ${String(claimCase.status)}`, ); } if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) { @@ -5279,21 +5376,41 @@ export class ClaimRequestManagementService { `Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`, ); } - if (claimCase.claimStatus !== ClaimStatus.APPROVED) { + + 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( - `Claim must be insurer-approved before owner signature. Current claimStatus: ${String(claimCase.claimStatus)}`, + "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 (claimCase.evaluation?.ownerInsurerApproval?.signedAt) { - throw new ConflictException("You have already submitted a signature for this claim."); + if (isMixedPartialGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) { + throw new ConflictException("Claim already has a final approval record; refresh the claim state."); } - - const activeReply = - claimCase.evaluation?.damageExpertReplyFinal || - claimCase.evaluation?.damageExpertReply; - if (!activeReply?.submittedAt) { - throw new BadRequestException("No expert reply is available to sign off on yet."); + if (isFinalApprovalGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) { + throw new ConflictException("You have already submitted a signature for this claim."); } const rawBranchId = (branchId ?? "").trim(); @@ -5312,9 +5429,10 @@ export class ClaimRequestManagementService { "This branch does not belong to the insurer for this claim.", ); } - const branchIdsOnPricing = this.collectBranchIdsFromClaimExpertReply( - activeReply as { parts?: unknown[] }, - ); + const branchIdsOnPricing = + isMixedPartialGate + ? this.collectBranchIdsFromClaimExpertReply(pricedBranchReply) + : this.collectBranchIdsFromClaimExpertReply(active as { parts?: unknown[] }); if ( branchIdsOnPricing.size > 0 && !branchIdsOnPricing.has(String(rawBranchId)) @@ -5332,7 +5450,7 @@ export class ClaimRequestManagementService { } as any); const signId = new Types.ObjectId(String((signDoc as any)._id)); const signedAt = new Date(); - const approvalPayload = { + const scopedApprovalPayload = { agree, branchId: new Types.ObjectId(rawBranchId), signDetailId: signId, @@ -5345,12 +5463,73 @@ export class ClaimRequestManagementService { 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: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, + 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": approvalPayload, + "evaluation.ownerInsurerApproval": scopedApprovalPayload, "workflow.nextStep": null, }, $push: { @@ -5370,6 +5549,7 @@ export class ClaimRequestManagementService { claimStatus: ClaimStatus.REJECTED, currentStep: claimCase.workflow?.currentStep, accepted: false, + phase: "FINAL_APPROVAL", }; } @@ -5377,7 +5557,7 @@ export class ClaimRequestManagementService { $set: { status: ClaimCaseStatus.COMPLETED, claimStatus: ClaimStatus.APPROVED, - "evaluation.ownerInsurerApproval": approvalPayload, + "evaluation.ownerInsurerApproval": scopedApprovalPayload, "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, @@ -5399,6 +5579,7 @@ export class ClaimRequestManagementService { claimStatus: ClaimStatus.APPROVED, currentStep: ClaimWorkflowStep.CLAIM_COMPLETED, accepted: true, + phase: "FINAL_APPROVAL", }; } @@ -5458,7 +5639,8 @@ export class ClaimRequestManagementService { } /** - * V2 API: Get claim details by ID (owner only) + * 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, @@ -5618,6 +5800,14 @@ export class ClaimRequestManagementService { damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal, } : undefined, + ...(!isExpertViewer + ? { + ownerGuidance: buildClaimDetailsV2OwnerGuidance( + claim, + claim._id.toString(), + ), + } + : {}), ...(isExpertViewer ? { userRating: claim.userRating diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index 78b650a..adbd17b 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -85,7 +85,7 @@ export class ClaimRequestManagementV2Controller { @ApiOperation({ summary: "Get Claim Details (V2)", description: - "Get claim details for current actor. USER (claim owner) receives the same core claim payload including `owner` (userId, clientId, optional userClientKey). FIELD_EXPERT additionally receives unmasked money and userRating when present.", + "Returns the claim snapshot for **USER** (owner) or **FIELD_EXPERT** when permitted. Owners get `ownerGuidance`: `{ phaseKey, headline, nextActions[] (method + pathTemplate), objectionAllowed }` mapped from `status` / `claimStatus` / workflow so the client can show the correct screen without duplicating orchestration logic. FIELD_EXPERT does not receive `ownerGuidance`. Core payload includes documents, captures, evaluation replies, optional expert resend, and masked bank info.", }) @ApiResponse({ status: 200, @@ -156,9 +156,11 @@ export class ClaimRequestManagementV2Controller { @ApiOperation({ summary: "Submit user objection (V2)", description: - "After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " + - "and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " + - "`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.", + "**Windows:** (1) **Insurer-review:** `WAITING_FOR_INSURER_APPROVAL` + `workflow.currentStep=INSURER_REVIEW` without a recorded **final** `evaluation.ownerInsurerApproval` signature — including **mixed** priced+factor gate (`NEEDS_REVISION` before factor upload signature) or **final** totals (`claimStatus=APPROVED`). Not allowed while `OWNER_UPLOAD_FACTOR_DOCUMENTS` or `EXPERT_COST_EVALUATION` (finish factor uploads / validation first).\n" + + "(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" + + "`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" + + "After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" + + "Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.", }) @ApiParam({ name: "claimRequestId", @@ -233,7 +235,7 @@ export class ClaimRequestManagementV2Controller { } /** - * V2: Owner signs acceptance of final expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW). + * V2: Owner signature — priced-line gate (mixed factors) or final accept/reject. */ @Put("request/:claimRequestId/owner-insurer-approval/sign") @ApiParam({ @@ -243,12 +245,12 @@ export class ClaimRequestManagementV2Controller { }) @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "Sign final claim pricing (owner)", + summary: "Sign priced lines or final claim pricing (owner)", description: - "Multipart: `sign` (signature image), `agree` (boolean), and `branchId` (Mongo ObjectId of the insurer branch the file is reviewed under). " + - "Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " + - "`workflow.currentStep` is INSURER_REVIEW (not EXPERT_COST_EVALUATION), and `claimStatus` is APPROVED. " + - "If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.", + "Multipart: `sign`, `agree`, `branchId`. Always requires `status=WAITING_FOR_INSURER_APPROVAL` and `workflow.currentStep=INSURER_REVIEW` (never during `EXPERT_COST_EVALUATION`).\n\n" + + "**Phase A — Mixed reply, priced lines only:** `claimStatus=NEEDS_REVISION`, no `evaluation.ownerPricedPartsApproval` yet. `agree=true` records that signature and moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads; `agree=false` rejects the whole case (`REJECTED`).\n\n" + + "**Phase B — Final:** `claimStatus=APPROVED`, no `evaluation.ownerInsurerApproval` yet. `agree=true` → `COMPLETED`; `agree=false` → `REJECTED`.\n\n" + + "Response may include `phase`: `PRICED_PARTS_FOR_FACTORS` or `FINAL_APPROVAL` for UI state.", }) @ApiBody({ description: "Signature file, agreement, and branch", @@ -890,12 +892,12 @@ Returns status of each item (uploaded/captured or not). @Patch("request/reply/:claimRequestId/:partId/upload-factor") @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "Upload factor file for a priced part (V2)", + summary: "Upload repair factor file for a factor-needed part (V2)", description: - "Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " + - "Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " + - "`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " + - "Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.", + "Part must have `factorNeeded: true` on the active reply (`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`). One file per part.\n\n" + + "**Requires:** `status=WAITING_FOR_INSURER_APPROVAL`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`.\n\n" + + "**Mixed priced+factor replies:** owner must complete **owner-insurer-approval/sign** (priced-line phase) first so `evaluation.ownerPricedPartsApproval` exists.\n\n" + + "When every `factorNeeded` line has `factorLink`, the case moves to `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION` for damage expert validation.", }) @ApiParam({ name: "claimRequestId", description: "Claim case ID" }) @ApiParam({ name: "partId", description: "Part id from expert reply" }) diff --git a/src/claim-request-management/dto/claim-details-v2.dto.ts b/src/claim-request-management/dto/claim-details-v2.dto.ts index 63f0eb0..b7a4c6d 100644 --- a/src/claim-request-management/dto/claim-details-v2.dto.ts +++ b/src/claim-request-management/dto/claim-details-v2.dto.ts @@ -1,5 +1,63 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */ +export class ClaimDetailsOwnerNextActionV2Dto { + @ApiProperty({ description: 'Stable UI key', example: 'FINAL_SIGN' }) + key: string; + + @ApiPropertyOptional({ description: 'HTTP verb', example: 'PUT' }) + method?: string; + + @ApiPropertyOptional({ + description: 'Path under API root', + example: 'v2/claim-request-management/request/{claimRequestId}/owner-insurer-approval/sign', + }) + pathTemplate?: string; + + @ApiProperty({ description: 'What this endpoint does for the user' }) + description: string; +} + +/** Server-derived hints: which phase the claim is in and what to offer next (owners only). */ +export class ClaimDetailsOwnerGuidanceV2Dto { + @ApiProperty({ + description: 'Machine-readable phase', + enum: [ + 'COMPLETED', + 'REJECTED', + 'CANCELLED', + 'EXPERT_RESEND', + 'WAITING_DAMAGE_EXPERT', + 'EXPERT_REVIEWING', + 'USER_FLOW', + 'UPLOAD_FACTORS', + 'EXPERT_VALIDATING_FACTORS', + 'SIGN_PRICED_LINES', + 'INSURER_REVIEW_NEEDS_REVISION', + 'FINAL_SIGN_OR_REJECT', + 'INSURER_APPROVAL_FALLBACK', + ], + }) + phaseKey: string; + + @ApiProperty({ description: 'Short headline for the current phase' }) + headline: string; + + @ApiPropertyOptional({ description: 'Longer UX copy' }) + detail?: string; + + @ApiProperty({ type: [ClaimDetailsOwnerNextActionV2Dto] }) + nextActions: ClaimDetailsOwnerNextActionV2Dto[]; + + @ApiPropertyOptional({ + description: 'Whether PUT objection is permitted (see server validation for exact rules)', + }) + objectionAllowed?: boolean; + + @ApiPropertyOptional({ description: 'How objection relates to priced vs factor-only lines' }) + objectionHint?: string; +} + /** Active damage-expert resend request (owner must upload or acknowledge). */ export class ExpertResendDetailsV2Dto { @ApiPropertyOptional() @@ -97,6 +155,13 @@ export class ClaimDetailsV2ResponseDto { damageExpertReplyFinal?: unknown; }; + @ApiPropertyOptional({ + type: ClaimDetailsOwnerGuidanceV2Dto, + description: + 'Owner-only: derived headline, suggested API routes, and whether objection is plausible. Omitted when the actor is FIELD_EXPERT.', + }) + ownerGuidance?: ClaimDetailsOwnerGuidanceV2Dto; + @ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' }) userRating?: { progressSpeed: number; diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 5e58798..ba861d1 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -95,6 +95,24 @@ export class ClaimOwnerInsurerApproval { export const ClaimOwnerInsurerApprovalSchema = SchemaFactory.createForClass(ClaimOwnerInsurerApproval); +/** Owner signed acceptance of priced lines only — required before uploading factors when reply is mixed priced + factorNeeded. */ +@Schema({ _id: false }) +export class ClaimOwnerPricedPartsApproval { + @Prop({ type: Boolean, required: true }) + agree: boolean; + + @Prop({ type: Types.ObjectId }) + branchId?: Types.ObjectId; + + @Prop({ type: Types.ObjectId }) + signDetailId?: Types.ObjectId; + + @Prop({ type: Date, default: () => new Date() }) + signedAt?: Date; +} +export const ClaimOwnerPricedPartsApprovalSchema = + SchemaFactory.createForClass(ClaimOwnerPricedPartsApproval); + @Schema({ _id: false }) export class ClaimExpertReply { @Prop({ type: String }) @@ -276,6 +294,9 @@ export class ClaimEvaluation { @Prop({ type: ClaimOwnerInsurerApprovalSchema }) ownerInsurerApproval?: ClaimOwnerInsurerApproval; + @Prop({ type: ClaimOwnerPricedPartsApprovalSchema }) + ownerPricedPartsApproval?: ClaimOwnerPricedPartsApproval; + @Prop({ type: String }) visitLocation?: string; diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index a6b2dba..e8c0c5c 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -80,7 +80,11 @@ export class PartPricingV2Dto { @Type(() => DaghiDetailsV2Dto) daghi: DaghiDetailsV2Dto; - @ApiProperty({ example: false }) + @ApiProperty({ + example: false, + description: + "If true, the owner must upload a repair factor image for this line; expert confirms cost later (validate-factors). Priced lines omit this or set false.", + }) @IsBoolean() factorNeeded: boolean; } @@ -91,7 +95,11 @@ export class SubmitExpertReplyV2Dto { @IsNotEmpty() description: string; - @ApiProperty({ type: [PartPricingV2Dto] }) + @ApiProperty({ + type: [PartPricingV2Dto], + description: + "Each part: manual pricing and `daghi`; set `factorNeeded` only where a repair factor upload is required from the owner.", + }) @IsArray() @ValidateNested({ each: true }) @Type(() => PartPricingV2Dto) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 1299000..97f2f01 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -65,6 +65,9 @@ import { enrichBlamePartiesForAgreementView, } from "src/helpers/blame-party-agreement-decision"; import { resendRequestHasPayload } from "src/helpers/claim-expert-resend"; +import { + classifyV2ExpertPricingParts, +} from "src/helpers/claim-v2-expert-reply-workflow"; import { getClaimCarAngleCaptureBlob, getDamagedPartCaptureBlob, @@ -1907,8 +1910,8 @@ export class ExpertClaimService { /** * V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply. * Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. - * — All approved → same as non-factor path: claimStatus APPROVED, step INSURER_REVIEW. - * — Any rejected (expert repriced) → case COMPLETED with expert amounts (damage-expert finalization). + * — All approved → claimStatus APPROVED, INSURER_REVIEW: owner accepts/rejects to close. + * — Any rejected (expert repriced) → WAITING_FOR_INSURER_APPROVAL, APPROVED, INSURER_REVIEW until owner accepts/rejects. */ async validateClaimFactorsV2( claimRequestId: string, @@ -2072,14 +2075,14 @@ export class ExpertClaimService { if (anyRejected) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { - status: ClaimCaseStatus.COMPLETED, + status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, claimStatus: ClaimStatus.APPROVED, - "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, + "workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW, "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, }, $push: { history: { - type: "FACTORS_VALIDATED_REJECTED_EXPERT_FINALIZED", + type: "FACTORS_VALIDATED_REJECTED_EXPERT_REPRICED", actor: historyActor, timestamp: new Date(), metadata: { replyField }, @@ -2087,13 +2090,26 @@ export class ExpertClaimService { }, }); + const ownerPhoneRejected = await this.resolveClaimOwnerPhone(claim); + if (ownerPhoneRejected) { + const expertLastName = + actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; + await this.smsOrchestrationService.sendSignatureReviewNotice({ + receptor: ownerPhoneRejected, + fileKind: "claim", + publicId: claim.publicId, + expertLastName, + link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), + }); + } + return { message: - "Factors reviewed. Rejected parts were repriced by the expert; the claim is completed.", + "Factors reviewed with repricing. Owner must accept or reject via owner-insurer-approval/sign.", claimRequestId, claimStatus: ClaimStatus.APPROVED, - caseStatus: ClaimCaseStatus.COMPLETED, - outcome: "REJECTED_REPRICED_COMPLETED", + caseStatus: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, + outcome: "REJECTED_REPRICED_OWNER_SIGN", }; } @@ -2350,6 +2366,12 @@ export class ExpertClaimService { ); } + if (existingResend?.fulfilledAt) { + throw new BadRequestException( + "The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.", + ); + } + const allowedDocTypes = new Set( Object.values(ClaimRequiredDocumentType), ); @@ -2458,10 +2480,11 @@ export class ExpertClaimService { * - Each part must include `daghi` (option + conditional price/branchId) like V1 * * On success: - * - Stores reply in evaluation.damageExpertReply + * - Stores reply; clears owner signature fields (`ownerInsurerApproval`, `ownerPricedPartsApproval`) * - Unlocks the workflow - * - If any part has factorNeeded=true → status = WAITING_FOR_INSURER_APPROVAL, step = EXPERT_COST_EVALUATION - * - Otherwise → status = WAITING_FOR_INSURER_APPROVAL, step = INSURER_REVIEW, claimStatus = APPROVED + * - Pricing-only (`factorNeeded=false` everywhere): WAITING_FOR_INSURER_APPROVAL, APPROVED, INSURER_REVIEW → owner final sign/reject. + * - All lines `factorNeeded`: NEEDS_REVISION, OWNER_UPLOAD_FACTOR_DOCUMENTS → uploads → UNDER_REVIEW, EXPERT_COST_EVALUATION (validate factors). + * - Mixed priced + factor: NEEDS_REVISION, INSURER_REVIEW with next OWNER_UPLOAD_FACTOR_DOCUMENTS → owner signs priced lines first → upload factors → expert validates → final sign. */ async submitExpertReplyV2( claimRequestId: string, @@ -2514,7 +2537,11 @@ export class ExpertClaimService { ? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts) : []; - const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false; + const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts( + processedParts, + ); + const needsFactorUpload = + reply.parts?.some((p) => p.factorNeeded === true) ?? false; const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt; const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal; @@ -2546,14 +2573,22 @@ export class ExpertClaimService { ...(expertProfileSnapshot && { expertProfileSnapshot }), }; - const nextStep = needsFactorUpload - ? ClaimWorkflowStep.INSURER_REVIEW - : ClaimWorkflowStep.CLAIM_COMPLETED; + let currentStep = ClaimWorkflowStep.INSURER_REVIEW; + let nextWorkflowStep = ClaimWorkflowStep.CLAIM_COMPLETED; const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL; - const nextClaimStatus = needsFactorUpload - ? ClaimStatus.NEEDS_REVISION - : ClaimStatus.APPROVED; + let nextClaimStatus = ClaimStatus.APPROVED; + + if (needsFactorUpload) { + nextClaimStatus = ClaimStatus.NEEDS_REVISION; + if (mixedFactorAndPrice) { + currentStep = ClaimWorkflowStep.INSURER_REVIEW; + nextWorkflowStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS; + } else { + currentStep = ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS; + nextWorkflowStep = ClaimWorkflowStep.EXPERT_COST_EVALUATION; + } + } const updatePayload: Record = { status: nextCaseStatus, @@ -2564,11 +2599,11 @@ export class ExpertClaimService { 'workflow.expiredAt': '', 'workflow.lockedBy': '', 'workflow.preLockQueueSnapshot': '', + 'evaluation.ownerInsurerApproval': "", + 'evaluation.ownerPricedPartsApproval': "", }, - 'workflow.currentStep': nextStep, - 'workflow.nextStep': needsFactorUpload - ? ClaimWorkflowStep.EXPERT_COST_EVALUATION - : ClaimWorkflowStep.CLAIM_COMPLETED, + 'workflow.currentStep': currentStep, + 'workflow.nextStep': needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED, [`evaluation.${replyField}`]: replyPayload, $push: { 'workflow.completedSteps': completedStep, @@ -2584,6 +2619,8 @@ export class ExpertClaimService { timestamp: new Date(), metadata: { factorNeeded: needsFactorUpload, + mixedFactorAndPrice, + allFactorLines, partsCount: processedParts.length, isFinalReplyAfterObjection, replyField, @@ -2602,30 +2639,28 @@ export class ExpertClaimService { idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`, }); - // Same effective step as WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW: owner reviews/signs (yara-signature). - if (!needsFactorUpload) { - const ownerPhone = await this.resolveClaimOwnerPhone(claim); - if (ownerPhone) { - const expertLastName = - actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; - await this.smsOrchestrationService.sendSignatureReviewNotice({ - receptor: ownerPhone, - fileKind: "claim", - publicId: claim.publicId, - expertLastName, - link: this.smsOrchestrationService.buildClaimLink( - String(claim._id), - ), - }); - } + const ownerPhoneNotify = await this.resolveClaimOwnerPhone(claim); + if (ownerPhoneNotify && !needsFactorUpload) { + const expertLastName = + actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; + await this.smsOrchestrationService.sendSignatureReviewNotice({ + receptor: ownerPhoneNotify, + fileKind: "claim", + publicId: claim.publicId, + expertLastName, + link: this.smsOrchestrationService.buildClaimLink(String(claim._id)), + }); } return { claimRequestId, status: nextCaseStatus, claimStatus: nextClaimStatus, - currentStep: nextStep, + currentStep, + workflowNextStep: needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED, factorNeeded: needsFactorUpload, + mixedPricingAndFactors: mixedFactorAndPrice, + allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines, isFinalReplyAfterObjection, }; } diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index e555de3..b34aa22 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -86,7 +86,12 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Submit expert damage assessment reply", description: - "Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. Each part requires `daghi` with the same rules as V1 (DaghiOption, price for recycled value, branchId for deliver damaged part). If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.", + "**Preconditions:** claim locked by this expert (`EXPERT_REVIEWING`). **Unlocks** the claim. Each part needs `daghi` (V1 rules) and may set `factorNeeded` (repair factor file required from owner) or priced lines only. **Cap:** sum of `totalPayment` ≤ 30,000,000. Clears any prior `evaluation.ownerInsurerApproval` / `ownerPricedPartsApproval`.\n\n" + + "**Frontend routing by outcome:**\n" + + "- **All parts `factorNeeded`:** `status=WAITING_FOR_INSURER_APPROVAL`, `claimStatus=NEEDS_REVISION`, `workflow.currentStep=OWNER_UPLOAD_FACTOR_DOCUMENTS`, `workflow.nextStep=EXPERT_COST_EVALUATION` → owner uploads all factors, then `claimStatus` becomes `UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION` for expert validate-factors.\n" + + "- **Mixed (some priced, some factorNeeded):** same `status`, `claimStatus=NEEDS_REVISION`, `currentStep=INSURER_REVIEW`, `nextStep=OWNER_UPLOAD_FACTOR_DOCUMENTS` → owner must call **owner-insurer-approval/sign** first (priced-line acceptance), then `currentStep` moves to `OWNER_UPLOAD_FACTOR_DOCUMENTS` for factor uploads, then expert validation.\n" + + "- **No factors:** `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW`, `nextStep=CLAIM_COMPLETED` → owner final sign/reject only.\n\n" + + "**After the owner fulfilled an expert resend** (`damageExpertResend.fulfilledAt`), this expert **cannot** initiate **another resend**—use **reply/submit**, in-person visit, or **validate-factors** as appropriate.", }) @ApiParam({ name: "claimRequestId" }) @ApiBody({ type: SubmitExpertReplyV2Dto }) @@ -103,7 +108,8 @@ export class ExpertClaimV2Controller { summary: "Submit expert damage resend request", description: "Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " + - "The owner uploads via the same document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.", + "Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" + + "**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.", }) @ApiParam({ name: "claimRequestId" }) @ApiBody({ type: ClaimSubmitResendV2Dto }) @@ -139,7 +145,12 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Validate uploaded repair factors (V2 ClaimCase)", description: - "After all required factors are uploaded, expert approves or rejects each part. Rejected parts must include expert totalPayment (or price and salary). All approved → insurer review. Any rejected → expert repricing and case COMPLETED.", + "**Preconditions:** `status=WAITING_FOR_INSURER_APPROVAL`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_COST_EVALUATION`, every `factorNeeded` line has `factorLink`.\n\n" + + "**Decisions:** each factor line gets `APPROVED` or `REJECTED`; rejected lines require expert `totalPayment` (or both `price` and `salary`).\n\n" + + "**Outcomes:**\n" + + "- **All approved:** `claimStatus=APPROVED`, `currentStep=INSURER_REVIEW` → owner must **sign or reject** via `owner-insurer-approval/sign`; agree completes the case.\n" + + "- **Any rejected (repriced):** same waiter + `INSURER_REVIEW` → owner still must **accept or reject** the repriced totals (case is not completed until they sign).\n" + + "- **Partial batch:** service may return pending until every factor line has a non-pending decision.", }) @ApiParam({ name: "claimRequestId" }) @ApiBody({ type: FactorValidationV2Dto }) diff --git a/src/helpers/claim-details-v2-owner-guidance.ts b/src/helpers/claim-details-v2-owner-guidance.ts new file mode 100644 index 0000000..afb74cf --- /dev/null +++ b/src/helpers/claim-details-v2-owner-guidance.ts @@ -0,0 +1,339 @@ +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.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 { + classifyV2ExpertPricingParts, + getActiveV2ExpertReply, + objectionDisallowedDueToOutstandingFactorWorkflow, +} from "src/helpers/claim-v2-expert-reply-workflow"; + +const BASE = "v2/claim-request-management"; + +/** One actionable next step for owner UI (`pathTemplate` embeds claim id; replace `{partId}` per line item). */ +export interface ClaimDetailsOwnerNextActionV2 { + key: string; + method?: string; + pathTemplate?: string; + description: string; +} + +/** Derived hints so the client can route without re-implementing server rules (returned only for claim owners, not FIELD_EXPERT). */ +export interface ClaimDetailsOwnerGuidanceV2 { + phaseKey: string; + headline: string; + detail?: string; + nextActions: ClaimDetailsOwnerNextActionV2[]; + objectionAllowed?: boolean; + objectionHint?: string; +} + +function objectionWindowForOwner(claim: any): { + pricingEligible: boolean; + resendEligible: boolean; +} { + const r = claim.evaluation?.damageExpertResend; + const hasExpertResend = !!( + r && + (String(r.resendDescription || "").trim() !== "" || + (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) || + (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0)) + ); + + const pendingResend = + hasExpertResend && + claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && + claim.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND; + + let pricingEligible = false; + if ( + claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && + claim.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW && + !claim.evaluation?.ownerInsurerApproval?.signedAt + ) { + if (!objectionDisallowedDueToOutstandingFactorWorkflow(claim)) { + const active = getActiveV2ExpertReply( + claim as { evaluation?: Record }, + ); + const shape = classifyV2ExpertPricingParts(active?.parts ?? []); + const atMixed = + claim.claimStatus === ClaimStatus.NEEDS_REVISION && + shape.mixedFactorAndPrice && + !claim.evaluation?.ownerPricedPartsApproval?.signedAt; + const atFinal = claim.claimStatus === ClaimStatus.APPROVED; + pricingEligible = atMixed || atFinal; + } + } + + return { pricingEligible, resendEligible: !!pendingResend }; +} + +/** Mirrors server rules for PUT .../objection (read-only hint for UI). */ +export function ownerMaySubmitObjectionForClaimDetails(claim: any): boolean { + if (claim.evaluation?.objection?.submittedAt) return false; + if (claim.evaluation?.damageExpertReplyFinal) return false; + const { pricingEligible, resendEligible } = objectionWindowForOwner(claim); + if (resendEligible) return true; + return ( + pricingEligible && + !claim.evaluation?.ownerInsurerApproval?.signedAt && + !objectionDisallowedDueToOutstandingFactorWorkflow(claim) + ); +} + +function objectionHintWhen(allowed: boolean): string | undefined { + return allowed + ? "Optional: PUT v2/claim-request-management/request/{claimRequestId}/objection — only priced repair lines (`factorNeeded=false` in the expert reply)." + : undefined; +} + +/** + * Build owner-facing guidance for GET claim details V2. + * Caller should omit this field for FIELD_EXPERT viewers. + */ +export function buildClaimDetailsV2OwnerGuidance( + claim: any, + claimRequestId: string, +): ClaimDetailsOwnerGuidanceV2 { + const id = claimRequestId; + const act = (key: string, method: string, path: string, description: string) => + ({ key, method, pathTemplate: path, description }); + + const objectionAllowed = ownerMaySubmitObjectionForClaimDetails(claim); + const objectionHint = objectionHintWhen(objectionAllowed); + + if (claim.status === ClaimCaseStatus.COMPLETED) { + return { + phaseKey: "COMPLETED", + headline: "Claim completed", + detail: + claim.userRating?.createdAt + ? "Rating already submitted." + : "You may submit a satisfaction rating once.", + nextActions: [ + act( + "USER_RATING", + "PUT", + `${BASE}/request/${id}/user-rating`, + "Optional satisfaction rating", + ), + ], + objectionAllowed: false, + }; + } + + if (claim.status === ClaimCaseStatus.REJECTED || claim.claimStatus === ClaimStatus.REJECTED) { + return { + phaseKey: "REJECTED", + headline: "Claim rejected", + detail: "This case is closed as rejected.", + nextActions: [], + objectionAllowed: false, + }; + } + + if (claim.status === ClaimCaseStatus.CANCELLED) { + return { + phaseKey: "CANCELLED", + headline: "Claim cancelled", + nextActions: [], + objectionAllowed: false, + }; + } + + if (claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND) { + return { + phaseKey: "EXPERT_RESEND", + headline: "Expert requested more information", + detail: + "Upload requested documents or damaged-part photos, or acknowledge if the expert gave instructions only.", + nextActions: [ + act( + "UPLOAD_DOC", + "POST", + `${BASE}/upload-document/${id}`, + "Multipart: required document upload", + ), + act( + "CAPTURE_PART", + "POST", + `${BASE}/capture-part/${id}`, + "Multipart: damaged-part or angle capture", + ), + act( + "RESEND_ACK", + "POST", + `${BASE}/request/${id}/expert-resend/acknowledge`, + "If no document/part list — instructions only", + ), + ], + objectionAllowed: ownerMaySubmitObjectionForClaimDetails(claim), + objectionHint: objectionHintWhen(ownerMaySubmitObjectionForClaimDetails(claim)), + }; + } + + if (claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) { + return { + phaseKey: "WAITING_DAMAGE_EXPERT", + headline: "Waiting for damage expert", + detail: "Your file is in the expert queue.", + nextActions: [], + objectionAllowed: false, + }; + } + + if (claim.status === ClaimCaseStatus.EXPERT_REVIEWING) { + return { + phaseKey: "EXPERT_REVIEWING", + headline: "Expert is reviewing", + detail: "A damage expert is assessing your claim.", + nextActions: [], + objectionAllowed: false, + }; + } + + if (claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) { + return { + phaseKey: "USER_FLOW", + headline: "Continue your claim registration", + detail: "Complete remaining selection, captures, documents, or bank info.", + nextActions: [ + act( + "CAPTURE_REQUIREMENTS", + "GET", + `${BASE}/capture-requirements/${id}`, + "Shows remaining angles, parts, documents", + ), + act( + "UPLOAD_DOC", + "POST", + `${BASE}/upload-document/${id}`, + "Upload required document when workflow allows", + ), + act( + "CAPTURE_PART", + "POST", + `${BASE}/capture-part/${id}`, + "Upload angle or damaged-part photo when workflow allows", + ), + ], + objectionAllowed: false, + }; + } + + const step = claim.workflow?.currentStep; + const cs = claim.claimStatus; + + if (step === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS && cs === ClaimStatus.NEEDS_REVISION) { + return { + phaseKey: "UPLOAD_FACTORS", + headline: "Upload repair factor files", + detail: + "One upload per factor-needed part. When all factors are uploaded, the expert validates costs.", + nextActions: [ + act( + "UPLOAD_FACTOR", + "PATCH", + `${BASE}/request/reply/${id}/{partId}/upload-factor`, + "Multipart `file` — use each expert reply `partId` with factorNeeded=true", + ), + ], + objectionAllowed: false, + }; + } + + if (step === ClaimWorkflowStep.EXPERT_COST_EVALUATION && cs === ClaimStatus.UNDER_REVIEW) { + return { + phaseKey: "EXPERT_VALIDATING_FACTORS", + headline: "Expert is validating factors", + detail: "No owner action until the expert finishes validating repair-factor pricing.", + nextActions: [], + objectionAllowed: false, + }; + } + + if (step === ClaimWorkflowStep.INSURER_REVIEW) { + if (cs === ClaimStatus.NEEDS_REVISION) { + const active = getActiveV2ExpertReply( + claim as { evaluation?: Record }, + ); + const shape = classifyV2ExpertPricingParts(active?.parts ?? []); + if ( + shape.mixedFactorAndPrice && + !claim.evaluation?.ownerPricedPartsApproval?.signedAt + ) { + const ow = objectionWindowForOwner(claim); + const allowObj = ow.pricingEligible; + return { + phaseKey: "SIGN_PRICED_LINES", + headline: "Sign acceptance of priced lines", + detail: + "Required before uploading repair-factor files for mixed priced + factor-needed replies.", + nextActions: [ + act( + "SIGN_PRICED_PARTS", + "PUT", + `${BASE}/request/${id}/owner-insurer-approval/sign`, + "Multipart sign + agree + branchId", + ), + ], + objectionAllowed: allowObj, + objectionHint: objectionHintWhen(allowObj), + }; + } + + /** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */ + const allowObj = objectionWindowForOwner(claim).pricingEligible; + return { + phaseKey: "INSURER_REVIEW_NEEDS_REVISION", + headline: "Insurer approval — action pending", + detail: + "If you see priced lines without factors, use sign or objection; otherwise verify workflow data.", + nextActions: [ + act( + "SIGN_OR_CHECK", + "PUT", + `${BASE}/request/${id}/owner-insurer-approval/sign`, + "Sign if prompted by app state", + ), + ], + objectionAllowed: allowObj, + objectionHint: objectionHintWhen(allowObj), + }; + } + + if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) { + const allowObj = objectionWindowForOwner(claim).pricingEligible; + return { + phaseKey: "FINAL_SIGN_OR_REJECT", + headline: "Accept or reject final pricing", + detail: "Sign to complete the claim, or reject. You may object to priced lines instead of signing.", + nextActions: [ + act( + "FINAL_SIGN", + "PUT", + `${BASE}/request/${id}/owner-insurer-approval/sign`, + "Multipart sign + agree + branchId — completes or rejects claim", + ), + act( + "OBJECTION", + "PUT", + `${BASE}/request/${id}/objection`, + "Dispute priced lines before signing", + ), + ], + objectionAllowed: allowObj, + objectionHint: objectionHintWhen(allowObj), + }; + } + } + + return { + phaseKey: "INSURER_APPROVAL_FALLBACK", + headline: "Awaiting next step", + detail: + "Inspect `status`, `claimStatus`, `workflow.currentStep`/`nextStep` or contact support.", + nextActions: [], + objectionAllowed, + objectionHint, + }; +} diff --git a/src/helpers/claim-v2-expert-reply-workflow.ts b/src/helpers/claim-v2-expert-reply-workflow.ts new file mode 100644 index 0000000..fc7616b --- /dev/null +++ b/src/helpers/claim-v2-expert-reply-workflow.ts @@ -0,0 +1,67 @@ +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.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"; + +export interface ClaimPricingPartLite { + factorNeeded?: boolean; + partId?: string; +} + +export function getActiveV2ExpertReply(claim: { evaluation?: Record }): { + replyKey: "damageExpertReplyFinal" | "damageExpertReply"; + reply: Record; + parts: ClaimPricingPartLite[]; +} | null { + const fin = claim.evaluation?.damageExpertReplyFinal as + | { parts?: ClaimPricingPartLite[]; submittedAt?: Date } + | undefined; + const ini = claim.evaluation?.damageExpertReply as + | { parts?: ClaimPricingPartLite[]; submittedAt?: Date } + | undefined; + if (fin?.submittedAt && fin?.parts?.length) { + return { replyKey: "damageExpertReplyFinal", reply: fin as Record, parts: fin.parts }; + } + if (ini?.submittedAt && ini?.parts?.length) { + return { replyKey: "damageExpertReply", reply: ini as Record, parts: ini.parts }; + } + return null; +} + +export function classifyV2ExpertPricingParts(parts: ClaimPricingPartLite[]) { + const factorNeeded = parts.filter((p) => p.factorNeeded === true); + const priced = parts.filter((p) => p.factorNeeded !== true); + return { + pricedPartsCount: priced.length, + factorNeededPartsCount: factorNeeded.length, + mixedFactorAndPrice: + factorNeeded.length > 0 && priced.length > 0, + allFactorLines: factorNeeded.length > 0 && priced.length === 0, + noFactors: factorNeeded.length === 0, + }; +} + +export function claimIsV2ExpertFactorUploadStep(claim: { workflow?: { currentStep?: string } }) { + return claim.workflow?.currentStep === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS; +} + +export function claimIsV2PendingFactorExpertValidation(claim: { + status?: ClaimCaseStatus; + claimStatus?: ClaimStatus; + workflow?: { currentStep?: string }; +}) { + return ( + claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && + claim.claimStatus === ClaimStatus.UNDER_REVIEW && + claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION + ); +} + +export function objectionDisallowedDueToOutstandingFactorWorkflow(claim: { + workflow?: { currentStep?: string }; +}) { + const step = claim.workflow?.currentStep; + return ( + step === ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS || + step === ClaimWorkflowStep.EXPERT_COST_EVALUATION + ); +}