diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index e21a783..674ea8a 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -113,12 +113,24 @@ import { catalogItemToSelectedPart, catalogLikeKeyFromPart, coerceDamagedPartsMediaToArray, + type DamageSelectedPartV2, migrateExpertReplyCarPartDamageToUnified, normalizeDamageSelectedParts, partIdentityKey, 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); +} + @Injectable() export class ClaimRequestManagementService { private readonly logger = new Logger(ClaimRequestManagementService.name); @@ -163,6 +175,79 @@ export class ClaimRequestManagementService { private readonly sandHubService: SandHubService, ) {} + private requiredDocumentKeysV2(isCarBody: boolean): string[] { + const damagedPartyKeys = [ + "damaged_driving_license_front", + "damaged_driving_license_back", + "damaged_chassis_number", + "damaged_engine_photo", + "damaged_car_card_front", + "damaged_car_card_back", + "damaged_metal_plate", + ]; + if (isCarBody) return damagedPartyKeys; + return [ + ...damagedPartyKeys, + "guilty_driving_license_front", + "guilty_driving_license_back", + "guilty_car_card_front", + "guilty_car_card_back", + "guilty_metal_plate", + ]; + } + + private isRequiredDocumentUploadedOnClaim(claimCase: any, key: string): boolean { + const doc = + (claimCase.requiredDocuments as any)?.get?.(key) ?? + (claimCase.requiredDocuments as any)?.[key]; + 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, + assumeUploadedKey?: string, + ): boolean { + for (const k of this.requiredDocumentKeysV2(isCarBody)) { + const ok = + k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k); + if (!ok) return false; + } + if (isCarBody) { + const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; + const okGreen = + assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g); + if (!okGreen) return false; + } + return true; + } + + private countRemainingV2OwnerDocuments( + claimCase: any, + isCarBody: boolean, + assumeUploadedKey?: string, + ): number { + let n = 0; + for (const k of this.requiredDocumentKeysV2(isCarBody)) { + const ok = + k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k); + if (!ok) n++; + } + if (isCarBody) { + const g = ClaimRequiredDocumentType.CAR_GREEN_CARD; + const okGreen = + assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g); + if (!okGreen) n++; + } + return n; + } + private parsePlateFromCompactString( plateId: string | undefined, ): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number } | null { @@ -219,7 +304,56 @@ export class ClaimRequestManagementService { return new Promise((resolve) => setTimeout(resolve, ms)); } - getOuterPartsCatalogV2(carType?: ClaimVehicleTypeV2): OuterPartCatalogItem[] { + /** + * Outer-parts catalog returned to clients (user app + expert panel). + * + * The shape intentionally mirrors how items are persisted under + * `damage.selectedParts` so the front-end can match catalog rows to the + * stored selection by `id` / `catalogKey` without renaming fields: + * + * { id, name, side, label_fa, catalogKey, carType } + * + * - `name` is side-agnostic (`backWheel`, not `left_backWheel`). + * - `label_fa` is disambiguated with the side in parentheses when multiple + * catalog rows share the same Farsi title (e.g. `چرخ عقب (چپ)`). + * - `catalogKey` keeps the original full key (`left_backWheel`) for clients + * that already address parts by it. + */ + getOuterPartsCatalogV2( + carType?: ClaimVehicleTypeV2, + ): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] { + const buildForType = ( + type: ClaimVehicleTypeV2, + items: OuterPartCatalogItem[], + ): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] => + items.map((p) => ({ + ...catalogItemToSelectedPart(p, items), + carType: type, + })); + + if (carType) { + const items = OUTER_PARTS_BY_CAR_TYPE[carType] || []; + return buildForType(carType, items); + } + + const out: (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] = []; + for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) { + out.push(...buildForType(type as ClaimVehicleTypeV2, items)); + } + return out.sort((a, b) => { + if (a.carType === b.carType) return (a.id ?? 0) - (b.id ?? 0); + return String(a.carType).localeCompare(String(b.carType)); + }); + } + + /** + * Internal raw catalog (kept in the original `OuterPartCatalogItem` shape so + * existing internal code that indexes by `key`/`titleFa` keeps working). + * Public API consumers should use `getOuterPartsCatalogV2` instead. + */ + private getOuterPartsRawCatalog( + carType?: ClaimVehicleTypeV2, + ): OuterPartCatalogItem[] { if (carType) { return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({ ...p, @@ -233,10 +367,7 @@ export class ClaimRequestManagementService { out.push({ ...p, carType: t }); } } - return out.sort((a, b) => { - if (a.carType === b.carType) return a.id - b.id; - return String(a.carType).localeCompare(String(b.carType)); - }); + return out; } private userDamageDetail(blRequest: RequestManagementModel) { @@ -4396,12 +4527,14 @@ export class ClaimRequestManagementService { throw new ForbiddenException('Only the claim owner can view capture requirements'); } - // Build car-type aware outer-parts lookup (complete source: static catalog) + // Build car-type aware outer-parts lookup (complete source: static catalog). + // Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the + // map below is keyed on the full `key` (e.g. `left_backfender`). const selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const catalogForType = selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType] ? OUTER_PARTS_BY_CAR_TYPE[selectedCarType] - : this.getOuterPartsCatalogV2(); + : this.getOuterPartsRawCatalog(); const catalogByKey = new Map(); for (const item of catalogForType) { if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item); @@ -4437,6 +4570,7 @@ export class ClaimRequestManagementService { label_en: doc.label_en, category: doc.category, uploaded: docData?.uploaded || false, + preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key), }; }); @@ -4655,10 +4789,23 @@ export class ClaimRequestManagementService { const step = claimCase.workflow?.currentStep; const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; - if (!isResendUpload && step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { - throw new BadRequestException( - `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, - ); + const isCapturePhaseDocUpload = + step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && + isCapturePhaseDamagedPartyDocKey(body.documentKey); + + 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(", ")}).`, + ); + } + const allowedInitialUploadStep = + step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || isCapturePhaseDocUpload; + if (!allowedInitialUploadStep) { + throw new BadRequestException( + `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, + ); + } } if (isResendUpload) { if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { @@ -4734,54 +4881,63 @@ export class ClaimRequestManagementService { let remaining = 0; if (!isResendUpload) { - // Check if all documents are uploaded (8 for CAR_BODY, 13 for THIRD_PARTY) - const totalDocsRequired = isCarBodyUpload ? 8 : 13; - const currentDocs = claimCase.requiredDocuments || new Map(); - const uploadedCount = - (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1; - allDocumentsUploaded = uploadedCount >= totalDocsRequired; - remaining = totalDocsRequired - uploadedCount; + if (isCapturePhaseDocUpload) { + const afterThis = (k: string) => + k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k); + remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter((k) => !afterThis(k)).length; + allDocumentsUploaded = false; + } else { + allDocumentsUploaded = this.allV2OwnerDocumentsComplete( + claimCase, + isCarBodyUpload, + body.documentKey, + ); + remaining = this.countRemainingV2OwnerDocuments( + claimCase, + isCarBodyUpload, + body.documentKey, + ); - // If all documents uploaded, user flow complete (captures were done earlier in v2) - if (allDocumentsUploaded) { - updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; - updateData["claimStatus"] = ClaimStatus.PENDING; - updateData["workflow.currentStep"] = - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; - updateData["workflow.nextStep"] = - ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; - updateData.$push.history = [ - updateData.$push.history, - { - type: "STEP_COMPLETED", - actor: { - actorId: new Types.ObjectId(currentUserId), - actorName: claimCase.owner?.fullName || "User", - actorType: "user", + if (allDocumentsUploaded) { + updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + updateData["claimStatus"] = ClaimStatus.PENDING; + updateData["workflow.currentStep"] = + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; + updateData["workflow.nextStep"] = + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; + updateData.$push.history = [ + updateData.$push.history, + { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: claimCase.owner?.fullName || "User", + actorType: "user", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + description: "All required documents uploaded", + }, }, - timestamp: new Date(), - metadata: { - stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - description: "All required documents uploaded", + { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: claimCase.owner?.fullName || "User", + actorType: "user", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + description: + "User submission complete. Claim ready for damage expert review.", + }, }, - }, - { - type: "STEP_COMPLETED", - actor: { - actorId: new Types.ObjectId(currentUserId), - actorName: claimCase.owner?.fullName || "User", - actorType: "user", - }, - timestamp: new Date(), - metadata: { - stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, - description: - "User submission complete. Claim ready for damage expert review.", - }, - }, - ]; - updateData.$push["workflow.completedSteps"] = - ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; + ]; + updateData.$push["workflow.completedSteps"] = + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; + } } } @@ -4808,18 +4964,24 @@ export class ClaimRequestManagementService { }; } - const message = allDocumentsUploaded - ? "All documents uploaded successfully. Your claim is now ready for damage expert review." - : `Document uploaded successfully. ${remaining} documents remaining.`; + const message = isCapturePhaseDocUpload + ? 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." + : allDocumentsUploaded + ? "All documents uploaded successfully. Your claim is now ready for damage expert review." + : `Document uploaded successfully. ${remaining} documents remaining.`; return { claimRequestId: claimCase._id.toString(), documentKey: body.documentKey, fileUrl, allDocumentsUploaded, - currentStep: allDocumentsUploaded - ? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE - : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + currentStep: isCapturePhaseDocUpload + ? ClaimWorkflowStep.CAPTURE_PART_DAMAGES + : allDocumentsUploaded + ? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE + : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, message, }; } catch (error) { @@ -5069,8 +5231,11 @@ export class ClaimRequestManagementService { ); }).length; + const capturePhaseDocsDone = this.capturePhaseDamagedPartyDocsComplete(updatedClaim); const allCapturesComplete = - anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length; + anglesCaptured >= 4 && + partsCaptured >= selectedNormAfter.length && + capturePhaseDocsDone; if (allCapturesComplete) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { @@ -5090,18 +5255,23 @@ export class ClaimRequestManagementService { metadata: { stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, description: - 'All car angles and damaged parts captured. Please upload required documents.', + 'Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.', }, }, }, }); } + const captureDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( + (k) => !this.isRequiredDocumentUploadedOnClaim(updatedClaim, k), + ).length; const remaining = - (4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured); + (4 - anglesCaptured) + + (selectedNormAfter.length - partsCaptured) + + captureDocsRemaining; const message = allCapturesComplete ? 'All captures complete. Please proceed to upload required documents.' - : `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`; + : `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} items remaining (angles, parts, and capture-phase documents).`; return { claimRequestId: claimCase._id.toString(), 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 b01a232..297d612 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -638,13 +638,13 @@ Optional: upload car green card file in the same step. summary: "Get Capture Requirements (V2)", description: ` **Get list of what needs to be captured:** -- Required documents (13 items) +- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item) - Car angles (4 items: front, back, left, right) - Damaged parts (based on selected outer parts) Returns status of each item (uploaded/captured or not). -**V2 order:** Complete angles and part photos first, then required documents. +**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\`. `, }) @ApiParam({ 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 b2b2d90..d1e6acb 100644 --- a/src/claim-request-management/dto/capture-requirements-v2.dto.ts +++ b/src/claim-request-management/dto/capture-requirements-v2.dto.ts @@ -35,6 +35,13 @@ export class RequiredDocumentItem { enum: ['general', 'damaged_party', 'guilty_party'], }) category: string; + + @ApiPropertyOptional({ + description: + 'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.', + example: true, + }) + preferUploadDuringCapture?: boolean; } /** diff --git a/src/claim-request-management/dto/select-outer-parts-v2.dto.ts b/src/claim-request-management/dto/select-outer-parts-v2.dto.ts index a4f7236..a1ef916 100644 --- a/src/claim-request-management/dto/select-outer-parts-v2.dto.ts +++ b/src/claim-request-management/dto/select-outer-parts-v2.dto.ts @@ -140,19 +140,54 @@ export class SetClaimVehicleTypeV2Dto { carType: ClaimVehicleTypeV2; } +/** + * Shape returned by `GET .../outer-parts-catalog` (both user and expert + * controllers). It mirrors how items are persisted under + * `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end + * can match catalog rows to stored selections without any field renaming: + * `name` is side-agnostic, `label_fa` is disambiguated with the side in + * parentheses, and the original full catalog key (`left_backfender`) is + * exposed as `catalogKey`. + */ export class OuterPartCatalogItemDto { - @ApiProperty() + @ApiProperty({ + description: "Static catalog id (unique across all car types)", + example: 102, + }) id: number; - @ApiProperty() - key: string; + @ApiProperty({ + description: "Side-agnostic part name (matches stored part `name`)", + example: "backWheel", + }) + name: string; - @ApiProperty() - titleFa: string; + @ApiProperty({ + description: "Vehicle side / region", + enum: OuterPartSideV2, + example: "left", + }) + side: string; - @ApiProperty({ enum: OuterPartSideV2 }) - side: OuterPartSideV2; + @ApiProperty({ + description: + "Display label in Farsi, with side disambiguator in parentheses when needed", + example: "چرخ عقب (چپ)", + }) + label_fa: string; - @ApiProperty({ enum: ClaimVehicleTypeV2, required: false }) + @ApiProperty({ + description: "Original full catalog key (matches stored `catalogKey`)", + example: "left_backWheel", + required: false, + }) + catalogKey?: string; + + @ApiProperty({ + description: "Vehicle type this catalog row belongs to", + enum: ClaimVehicleTypeV2, + required: false, + example: "suv", + }) carType?: ClaimVehicleTypeV2; } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 6f57538..cca21af 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -2523,7 +2523,7 @@ export class ExpertClaimService { } // Price cap validation - const PRICE_CAP = 30_000_000; + const PRICE_CAP = 53_000_000; let totalPrice = 0; for (const part of reply.parts || []) { const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));