diff --git a/package.json b/package.json index 0d369c6..6fb19e2 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,9 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + } } } diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 0711ef3..3f5f9d4 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -120,16 +120,13 @@ import { resolvePartCaptureIndex, } from "src/helpers/outer-damage-parts"; -/** Same `requiredDocuments` keys as in getCaptureRequirementsV2; upload allowed during CAPTURE_PART_DAMAGES. */ -const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [ - "damaged_chassis_number", - "damaged_engine_photo", - "damaged_metal_plate", -] as const; - -function isCapturePhaseDamagedPartyDocKey(key: string): boolean { - return (CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS as readonly string[]).includes(key); -} +import { + CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS, + capturePhaseSequenceMessage, + getClaimCaptureProgress, + isCapturePhaseDamagedPartyDocKey, + isClaimCaptureStepComplete, +} from "src/helpers/claim-capture-phase"; @Injectable() export class ClaimRequestManagementService { @@ -203,12 +200,6 @@ export class ClaimRequestManagementService { return !!doc?.uploaded; } - private capturePhaseDamagedPartyDocsComplete(claimCase: any): boolean { - return CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.every((k) => - this.isRequiredDocumentUploadedOnClaim(claimCase, k), - ); - } - private allV2OwnerDocumentsComplete( claimCase: any, isCarBody: boolean, @@ -4633,15 +4624,31 @@ export class ClaimRequestManagementService { const anglesCaptured = carAngles.filter(a => a.captured).length; const partsCaptured = damagedParts.filter(p => p.captured).length; - const totalRemaining = - (requiredDocuments.length - documentsUploaded) + - (carAngles.length - anglesCaptured) + - (damagedParts.length - partsCaptured); + const captureProgress = getClaimCaptureProgress(claimCase); + const capturePhaseDocsRemaining = captureProgress.capturePhaseDocsRemaining; + const partsRemaining = Math.max( + 0, + captureProgress.partsTotal - captureProgress.partsCaptured, + ); + const anglesRemaining = Math.max( + 0, + captureProgress.anglesTotal - captureProgress.anglesCaptured, + ); + const postCaptureDocsRemaining = requiredDocuments.filter( + (d) => !d.preferUploadDuringCapture && !d.uploaded, + ).length; + + const totalRemaining = + partsRemaining + anglesRemaining + capturePhaseDocsRemaining; return { claimRequestId: claimCase._id.toString(), publicId: claimCase.publicId, currentStep: claimCase.workflow?.currentStep || '', + captureSequencePhase: captureProgress.sequencePhase, + captureSequenceHint: capturePhaseSequenceMessage( + captureProgress.sequencePhase, + ), requiredDocuments, carAngles, damagedParts, @@ -4653,6 +4660,8 @@ export class ClaimRequestManagementService { anglesTotal: carAngles.length, partsCaptured, partsTotal: damagedParts.length, + capturePhaseDocsRemaining, + postCaptureDocumentsRemaining: postCaptureDocsRemaining, }, }; } catch (error) { @@ -4796,9 +4805,22 @@ export class ClaimRequestManagementService { if (!isResendUpload) { if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && !isCapturePhaseDocUpload) { throw new BadRequestException( - `During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}).`, + `During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}), and only after all damaged parts and car angles are captured.`, ); } + if (isCapturePhaseDocUpload) { + const captureProgress = getClaimCaptureProgress(claimCase); + if (!captureProgress.partsComplete) { + throw new BadRequestException( + "Upload photos for all selected damaged parts before uploading chassis, engine, or metal plate photos.", + ); + } + if (!captureProgress.anglesComplete) { + throw new BadRequestException( + "Capture all four car angles (front, back, left, right) before uploading chassis, engine, or metal plate photos.", + ); + } + } const allowedInitialUploadStep = step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || isCapturePhaseDocUpload; if (!allowedInitialUploadStep) { @@ -4896,31 +4918,14 @@ export class ClaimRequestManagementService { allDocumentsUploaded = false; if (remaining === 0) { - // After this upload all 3 capture-phase docs will be on the claim. - // Check angles + parts captures using the in-memory claim media. - const carAnglesData = (claimCase.media as any)?.carAngles; - const damagedPartsData = (claimCase.media as any)?.damagedParts; - const selectedNorm = normalizeDamageSelectedParts( - claimCase.damage?.selectedParts, - claimCase.vehicle?.carType as ClaimVehicleTypeV2, - (claimCase.damage as any)?.selectedOuterParts, - ); - const anglesKeys = ["front", "back", "left", "right"]; - const anglesCaptured = anglesKeys.filter((k) => - hasClaimCarAngleCapture( - carAnglesData, - damagedPartsData, - k as ClaimCarAngleKey, - ), - ).length; - const partsCaptured = selectedNorm.filter((sp) => { - const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); - return hasDamagedPartCapture(damagedPartsData, ck, selectedNorm); - }).length; + const progressAfterDoc = getClaimCaptureProgress(claimCase, { + assumeCapturePhaseDocKey: body.documentKey, + }); if ( - anglesCaptured >= 4 && - partsCaptured >= selectedNorm.length + progressAfterDoc.partsComplete && + progressAfterDoc.anglesComplete && + progressAfterDoc.capturePhaseDocsComplete ) { captureStepCompletedOnThisUpload = true; updateData["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS; @@ -5031,8 +5036,12 @@ export class ClaimRequestManagementService { ? captureStepCompletedOnThisUpload ? "Capture-phase documents and all damage captures are complete. Please proceed to upload the remaining required documents." : remaining > 0 - ? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate) before you can finish damage capture.` - : "Document saved. All capture-phase documents are uploaded; finish car angles and part photos to proceed." + ? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate).` + : capturePhaseSequenceMessage( + getClaimCaptureProgress(claimCase, { + assumeCapturePhaseDocKey: body.documentKey, + }).sequencePhase, + ) : allDocumentsUploaded ? "All documents uploaded successfully. Your claim is now ready for damage expert review." : `Document uploaded successfully. ${remaining} documents remaining.`; @@ -5114,6 +5123,19 @@ export class ClaimRequestManagementService { } } + if (!isResendCapture) { + const captureProgressBefore = getClaimCaptureProgress(claimCase); + const willBeAngle = + body.captureType === "angle" || + (body.captureType === "part" && + canonicalizeClaimCarAngleKey(body.captureKey) !== null); + if (willBeAngle && !captureProgressBefore.partsComplete) { + throw new BadRequestException( + "Upload photos for all selected damaged parts before capturing car angles.", + ); + } + } + const angleCanon = canonicalizeClaimCarAngleKey(body.captureKey); if (body.captureType === "angle" && !angleCanon) { throw new BadRequestException( @@ -5269,40 +5291,8 @@ export class ClaimRequestManagementService { }; } - const hasCapture = (data: any, key: string) => - data && (data instanceof Map ? data.get(key) : data[key]); - - // Use same logic as getCaptureRequirementsV2 to determine completion - const anglesKeys = ["front", "back", "left", "right"]; - const carAnglesData = updatedClaim?.media?.carAngles as any; - const damagedPartsData = updatedClaim?.media?.damagedParts as any; - const selectedNormAfter = normalizeDamageSelectedParts( - updatedClaim?.damage?.selectedParts, - updatedClaim?.vehicle?.carType as ClaimVehicleTypeV2, - (updatedClaim?.damage as any)?.selectedOuterParts, - ); - - const anglesCaptured = anglesKeys.filter((k) => - hasClaimCarAngleCapture( - carAnglesData, - damagedPartsData, - k as ClaimCarAngleKey, - ), - ).length; - const partsCaptured = selectedNormAfter.filter((sp) => { - const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); - return hasDamagedPartCapture( - damagedPartsData, - ck, - selectedNormAfter, - ); - }).length; - - const capturePhaseDocsDone = this.capturePhaseDamagedPartyDocsComplete(updatedClaim); - const allCapturesComplete = - anglesCaptured >= 4 && - partsCaptured >= selectedNormAfter.length && - capturePhaseDocsDone; + const captureProgress = getClaimCaptureProgress(updatedClaim); + const allCapturesComplete = isClaimCaptureStepComplete(updatedClaim); if (allCapturesComplete) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { @@ -5329,16 +5319,21 @@ export class ClaimRequestManagementService { }); } - const captureDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( - (k) => !this.isRequiredDocumentUploadedOnClaim(updatedClaim, k), - ).length; + const partsRemaining = Math.max( + 0, + captureProgress.partsTotal - captureProgress.partsCaptured, + ); + const anglesRemaining = Math.max( + 0, + captureProgress.anglesTotal - captureProgress.anglesCaptured, + ); const remaining = - (4 - anglesCaptured) + - (selectedNormAfter.length - partsCaptured) + - captureDocsRemaining; + partsRemaining + + anglesRemaining + + captureProgress.capturePhaseDocsRemaining; const message = allCapturesComplete - ? 'All captures complete. Please proceed to upload required documents.' - : `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} items remaining (angles, parts, and capture-phase documents).`; + ? "All captures complete. Please proceed to upload the remaining required documents." + : `${body.captureType === "angle" ? "Angle" : "Part"} captured successfully. ${capturePhaseSequenceMessage(captureProgress.sequencePhase)} (${remaining} item(s) left in this step).`; return { claimRequestId: claimCase._id.toString(), @@ -5393,6 +5388,18 @@ export class ClaimRequestManagementService { ); } + const captureProgress = getClaimCaptureProgress(claimCase); + if (!captureProgress.partsComplete) { + throw new BadRequestException( + "Upload photos for all selected damaged parts before uploading the walk-around video.", + ); + } + if (!captureProgress.anglesComplete) { + throw new BadRequestException( + "Capture all four car angles (front, back, left, right) before uploading the walk-around video.", + ); + } + if (claimCase.media?.videoCaptureId) { throw new ConflictException('A video has already been uploaded for this claim.'); } 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 8b533f1..0de6cab 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -649,7 +649,7 @@ Optional: upload car green card file in the same step. Returns status of each item (uploaded/captured or not). -**V2 order:** During \`CAPTURE_PART_DAMAGES\`, complete angles, part photos, walk-around video, and the three \`preferUploadDuringCapture\` documents (same upload-document API). Then complete the remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. +**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response. `, }) @ApiParam({ @@ -828,7 +828,7 @@ Returns status of each item (uploaded/captured or not). 1. **angle**: Car angles (front, back, left, right) - 4 required 2. **part**: Damaged parts based on selectedParts from Step 2 -**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). +**When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured. **Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party. `, diff --git a/src/claim-request-management/dto/capture-requirements-v2.dto.ts b/src/claim-request-management/dto/capture-requirements-v2.dto.ts index d1e6acb..7f223a8 100644 --- a/src/claim-request-management/dto/capture-requirements-v2.dto.ts +++ b/src/claim-request-management/dto/capture-requirements-v2.dto.ts @@ -144,6 +144,20 @@ export class GetCaptureRequirementsV2ResponseDto { }) currentStep: string; + @ApiProperty({ + description: + 'Ordered capture phase during CAPTURE_PART_DAMAGES: parts → angles → capture_phase_documents', + example: 'angles', + enum: ['parts', 'angles', 'capture_phase_documents', 'complete'], + }) + captureSequencePhase: string; + + @ApiProperty({ + description: 'Human-readable hint for what the user should do next in the capture step', + example: 'Capture all four car angles (front, back, left, right) next.', + }) + captureSequenceHint: string; + @ApiProperty({ description: 'List of required documents to upload', type: [RequiredDocumentItem], @@ -186,5 +200,7 @@ export class GetCaptureRequirementsV2ResponseDto { anglesTotal: number; partsCaptured: number; partsTotal: number; + capturePhaseDocsRemaining: number; + postCaptureDocumentsRemaining: number; }; } diff --git a/src/helpers/claim-capture-phase.spec.ts b/src/helpers/claim-capture-phase.spec.ts new file mode 100644 index 0000000..9dc7282 --- /dev/null +++ b/src/helpers/claim-capture-phase.spec.ts @@ -0,0 +1,95 @@ +import { + getClaimCaptureProgress, + isClaimCaptureStepComplete, +} from "./claim-capture-phase"; + +describe("claim-capture-phase", () => { + const baseClaim = { + vehicle: { carType: "sedan" }, + damage: { + selectedParts: [{ name: "hood", side: "front", label_fa: "کاپوت" }], + }, + media: { carAngles: {}, damagedParts: [] }, + requiredDocuments: {}, + }; + + it("starts in parts phase", () => { + const p = getClaimCaptureProgress(baseClaim); + expect(p.sequencePhase).toBe("parts"); + expect(p.partsComplete).toBe(false); + }); + + it("moves to angles after all parts", () => { + const claim = { + ...baseClaim, + media: { + carAngles: {}, + damagedParts: [ + { + name: "hood", + side: "front", + label_fa: "کاپوت", + path: "/x.jpg", + }, + ], + }, + }; + const p = getClaimCaptureProgress(claim); + expect(p.sequencePhase).toBe("angles"); + expect(p.partsComplete).toBe(true); + expect(p.anglesComplete).toBe(false); + }); + + it("does not complete capture step with only capture-phase docs uploaded", () => { + const claim = { + ...baseClaim, + media: { + carAngles: {}, + damagedParts: [ + { + name: "hood", + side: "front", + label_fa: "کاپوت", + path: "/x.jpg", + }, + ], + }, + requiredDocuments: { + damaged_chassis_number: { uploaded: true }, + damaged_engine_photo: { uploaded: true }, + damaged_metal_plate: { uploaded: true }, + }, + }; + expect(isClaimCaptureStepComplete(claim)).toBe(false); + expect(getClaimCaptureProgress(claim).sequencePhase).toBe("angles"); + }); + + it("is complete only when parts, angles, and capture-phase docs are done", () => { + const claim = { + ...baseClaim, + media: { + carAngles: { + front: { path: "/f.jpg" }, + back: { path: "/b.jpg" }, + left: { path: "/l.jpg" }, + right: { path: "/r.jpg" }, + }, + damagedParts: [ + { + name: "hood", + side: "front", + label_fa: "کاپوت", + path: "/x.jpg", + }, + ], + }, + requiredDocuments: { + damaged_chassis_number: { uploaded: true }, + damaged_engine_photo: { uploaded: true }, + damaged_metal_plate: { uploaded: true }, + }, + }; + expect(isClaimCaptureStepComplete(claim)).toBe(true); + expect(getClaimCaptureProgress(claim).sequencePhase).toBe("complete"); + }); +}); diff --git a/src/helpers/claim-capture-phase.ts b/src/helpers/claim-capture-phase.ts new file mode 100644 index 0000000..a3bffe7 --- /dev/null +++ b/src/helpers/claim-capture-phase.ts @@ -0,0 +1,127 @@ +import { + CLAIM_CAR_ANGLE_KEYS, + hasClaimCarAngleCapture, + hasDamagedPartCapture, +} from "src/helpers/claim-car-angle-media"; +import { + catalogLikeKeyFromPart, + normalizeDamageSelectedParts, +} from "src/helpers/outer-damage-parts"; +import type { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; + +/** Upload during CAPTURE_PART_DAMAGES (after parts + angles). */ +export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [ + "damaged_chassis_number", + "damaged_engine_photo", + "damaged_metal_plate", +] as const; + +export type CapturePhaseSequence = + | "parts" + | "angles" + | "capture_phase_documents" + | "complete"; + +export interface ClaimCaptureProgress { + partsCaptured: number; + partsTotal: number; + partsComplete: boolean; + anglesCaptured: number; + anglesTotal: number; + anglesComplete: boolean; + capturePhaseDocsComplete: boolean; + capturePhaseDocsRemaining: number; + sequencePhase: CapturePhaseSequence; +} + +export function isCapturePhaseDamagedPartyDocKey(key: string): boolean { + return (CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS as readonly string[]).includes( + key, + ); +} + +function isRequiredDocumentUploadedOnClaim( + claimCase: any, + key: string, +): boolean { + const doc = + (claimCase?.requiredDocuments as any)?.get?.(key) ?? + claimCase?.requiredDocuments?.[key]; + return !!doc?.uploaded; +} + +export function getClaimCaptureProgress( + claimCase: any, + options?: { assumeCapturePhaseDocKey?: string }, +): ClaimCaptureProgress { + const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const selectedNorm = normalizeDamageSelectedParts( + claimCase?.damage?.selectedParts, + carType, + claimCase?.damage?.selectedOuterParts, + ); + const carAnglesData = claimCase?.media?.carAngles; + const damagedPartsData = claimCase?.media?.damagedParts; + + const anglesCaptured = CLAIM_CAR_ANGLE_KEYS.filter((k) => + hasClaimCarAngleCapture(carAnglesData, damagedPartsData, k), + ).length; + const partsCaptured = selectedNorm.filter((sp) => { + const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); + return hasDamagedPartCapture(damagedPartsData, ck, selectedNorm); + }).length; + + const partsTotal = selectedNorm.length; + const partsComplete = partsTotal === 0 || partsCaptured >= partsTotal; + const anglesTotal = CLAIM_CAR_ANGLE_KEYS.length; + const anglesComplete = anglesCaptured >= anglesTotal; + + const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( + (k) => { + if (k === options?.assumeCapturePhaseDocKey) return false; + return !isRequiredDocumentUploadedOnClaim(claimCase, k); + }, + ).length; + const capturePhaseDocsComplete = capturePhaseDocsRemaining === 0; + + let sequencePhase: CapturePhaseSequence = "parts"; + if (partsComplete && !anglesComplete) { + sequencePhase = "angles"; + } else if (partsComplete && anglesComplete && !capturePhaseDocsComplete) { + sequencePhase = "capture_phase_documents"; + } else if (partsComplete && anglesComplete && capturePhaseDocsComplete) { + sequencePhase = "complete"; + } + + return { + partsCaptured, + partsTotal, + partsComplete, + anglesCaptured, + anglesTotal, + anglesComplete, + capturePhaseDocsComplete, + capturePhaseDocsRemaining, + sequencePhase, + }; +} + +export function isClaimCaptureStepComplete(claimCase: any): boolean { + const p = getClaimCaptureProgress(claimCase); + return ( + p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete + ); +} + +export function capturePhaseSequenceMessage(phase: CapturePhaseSequence): string { + switch (phase) { + case "parts": + return "Upload photos for all selected damaged parts first."; + case "angles": + return "Capture all four car angles (front, back, left, right) next."; + case "capture_phase_documents": + return "Upload chassis number, engine photo, and damaged metal plate photos last."; + default: + return "Damage capture is complete."; + } +}