diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 4d7afc1..04bd61d 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -99,8 +99,6 @@ import { hasClaimCarAngleCapture, hasDamagedPartCapture, legacyAngleDamagedPartFieldKeysToUnset, - legacyDamagedPartTitleEnFromPartKey, - resolveCanonicalDamagedPartStorageKey, type ClaimCarAngleKey, } from "src/helpers/claim-car-angle-media"; import { @@ -108,6 +106,16 @@ import { OUTER_PARTS_BY_CAR_TYPE, OuterPartCatalogItem, } from "src/static/outer-car-parts-catalog"; +import { + carPartDamageLabelForFactorRecord, + catalogItemToSelectedPart, + catalogLikeKeyFromPart, + coerceDamagedPartsMediaToArray, + migrateExpertReplyCarPartDamageToUnified, + normalizeDamageSelectedParts, + partIdentityKey, + resolvePartCaptureIndex, +} from "src/helpers/outer-damage-parts"; @Injectable() export class ClaimRequestManagementService { @@ -718,17 +726,32 @@ export class ClaimRequestManagementService { throw new BadRequestException("Car part damage is already set."); } this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); - const selectedParts = this.carDamagePartDtoToOuterPartSlugs(dto.carPartDamage); - if (!selectedParts.length) { + const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs( + dto.carPartDamage, + ); + if (!selectedSlugStrings.length) { throw new BadRequestException( "No outer damaged parts could be derived from carPartDamage. Check part selections.", ); } + const selectedPartObjs = normalizeDamageSelectedParts( + selectedSlugStrings, + working.vehicle?.carType as ClaimVehicleTypeV2 | undefined, + undefined, + ); + const damagedPartsStubs = selectedPartObjs.map((p) => ({ + id: p.id ?? undefined, + name: p.name, + side: p.side, + label_fa: p.label_fa, + ...(p.catalogKey ? { catalogKey: p.catalogKey } : {}), + })); const updated = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, { - "damage.selectedParts": selectedParts, + "damage.selectedParts": selectedPartObjs, + "media.damagedParts": damagedPartsStubs, status: ClaimCaseStatus.SELECTING_OTHER_PARTS, "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, @@ -746,7 +769,7 @@ export class ClaimRequestManagementService { timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, - selectedParts, + selectedParts: selectedPartObjs, description: "Field expert submitted outer damage parts", }, }, @@ -1924,7 +1947,10 @@ export class ClaimRequestManagementService { * - convert factorLink ObjectId -> public URL * - enrich daghi.branchId with branchName when available */ - private async mapEvaluationForClient(evaluation: any): Promise { + private async mapEvaluationForClient( + evaluation: any, + carType?: ClaimVehicleTypeV2, + ): Promise { if (!evaluation) return undefined; const mapReply = async (reply: any) => { @@ -1990,6 +2016,13 @@ export class ClaimRequestManagementService { }; } + if (nextPart.carPartDamage != null) { + nextPart.carPartDamage = migrateExpertReplyCarPartDamageToUnified( + nextPart.carPartDamage, + carType, + ); + } + return nextPart; }); @@ -2034,7 +2067,9 @@ export class ClaimRequestManagementService { const parts = finalReply?.parts || []; const missingFactors = parts .filter((p) => p.factorNeeded === true && !p.factorLink) - .map((p) => p.carPartDamage || p.partId); + .map((p) => + carPartDamageLabelForFactorRecord(p.carPartDamage) || p.partId, + ); if (missingFactors.length > 0) { throw new HttpException( @@ -2261,7 +2296,7 @@ export class ClaimRequestManagementService { const factorRecord = await this.claimFactorsImageDbService.create({ claimId, partId, - partName: part.carPartDamage, + partName: carPartDamageLabelForFactorRecord(part.carPartDamage), path: file.path, fileName: file.filename, uploadedAt: new Date(), @@ -2398,7 +2433,9 @@ export class ClaimRequestManagementService { ); } - const partNameForRecord = part.carPartDamage ?? part.partId; + const partNameForRecord = + carPartDamageLabelForFactorRecord(part.carPartDamage) || + String(part.partId ?? ""); const factorRecord = await this.claimFactorsImageDbService.create({ claimId: new Types.ObjectId(claimRequestId), @@ -3983,41 +4020,46 @@ export class ClaimRequestManagementService { ); } - selectedParts = selectedItems.map((p) => p.key); - const selectedPartIds = selectedItems.map((p) => p.id); - const selectedOuterParts = selectedItems.map((p) => ({ - id: p.id, - key: p.key, + const selectedPartDocs = selectedItems.map((p) => + catalogItemToSelectedPart(p, catalog), + ); + const damagedPartsInitial = selectedPartDocs.map((p) => ({ + id: p.id ?? undefined, + name: p.name, side: p.side, + label_fa: p.label_fa, + ...(p.catalogKey ? { catalogKey: p.catalogKey } : {}), })); + const selectedPartIds = selectedItems.map((p) => p.id); // 6. Update claim case with selected parts and move to next step const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, { - 'damage.selectedParts': selectedParts, - 'damage.selectedOuterParts': selectedOuterParts, - 'vehicle.carType': selectedCarType, - 'status': ClaimCaseStatus.SELECTING_OTHER_PARTS, - 'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS, - 'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + "damage.selectedParts": selectedPartDocs, + "media.damagedParts": damagedPartsInitial, + "vehicle.carType": selectedCarType, + status: ClaimCaseStatus.SELECTING_OTHER_PARTS, + "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, + "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + $unset: { "damage.selectedOuterParts": "" }, $push: { - 'workflow.completedSteps': ClaimWorkflowStep.SELECT_OUTER_PARTS, + "workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS, history: { - type: 'STEP_COMPLETED', + type: "STEP_COMPLETED", actor: { actorId: new Types.ObjectId(currentUserId), - actorName: claimCase.owner?.fullName || 'User', - actorType: 'user', + actorName: claimCase.owner?.fullName || "User", + actorType: "user", }, timestamp: new Date(), metadata: { stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, - selectedParts: selectedParts, + selectedParts: selectedPartDocs, selectedPartIds, carType: selectedCarType, - partsCount: selectedParts?.length, - description: `User selected ${selectedParts?.length} damaged outer parts`, + partsCount: selectedPartDocs?.length, + description: `User selected ${selectedPartDocs?.length} damaged outer parts`, }, }, }, @@ -4025,22 +4067,23 @@ export class ClaimRequestManagementService { ); if (!updatedClaim) { - throw new InternalServerErrorException('Failed to update claim case'); + throw new InternalServerErrorException("Failed to update claim case"); } this.logger.log( - `Outer parts selected for claim ${claimRequestId}: ${selectedParts?.length} parts`, + `Outer parts selected for claim ${claimRequestId}: ${selectedPartDocs?.length} parts`, ); // 7. Return response return { claimRequestId: updatedClaim._id.toString(), publicId: updatedClaim.publicId, - selectedParts: selectedParts, + selectedParts: selectedPartDocs, selectedPartIds, currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, - message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.', + message: + "Outer parts selected successfully. Please proceed to select other parts and provide bank information.", }; } catch (error) { if (error instanceof HttpException) throw error; @@ -4293,19 +4336,6 @@ export class ClaimRequestManagementService { if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item); } - const getPartLabel = (key: string): { fa: string; en: string } => { - const catalogItem = catalogByKey.get(key); - if (catalogItem) { - const en = key - .replace(/([a-z])([A-Z])/g, "$1 $2") - .replace(/_/g, " ") - .trim() - .replace(/\b\w/g, (c) => c.toUpperCase()); - return { fa: catalogItem.titleFa, en }; - } - return { fa: key, en: key }; - }; - // Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*) const blameRequest = claimCase.blameRequestId ? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString()) @@ -4359,46 +4389,36 @@ export class ClaimRequestManagementService { ), })); - // Damaged parts from selected parts - const selectedParts = claimCase.damage?.selectedParts || []; - const selectedOuterParts = Array.isArray(claimCase.damage?.selectedOuterParts) - ? claimCase.damage.selectedOuterParts - : []; - const selectedOuterPartByKey = new Map< - string, - { id?: number; side?: string } - >( - selectedOuterParts.map((p) => [ - p.key, - { - id: p.id, - side: p.side, - }, - ]), + const carType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const selectedNorm = normalizeDamageSelectedParts( + claimCase.damage?.selectedParts, + carType, + (claimCase.damage as any)?.selectedOuterParts, ); - const damagedParts = selectedParts.map(partKey => { - const label = getPartLabel(partKey); - const selectedMeta = selectedOuterPartByKey.get(partKey); - const catalogMeta = catalogByKey.get(partKey); - const side = selectedMeta?.side ?? catalogMeta?.side; - const sideFaMap: Record = { - left: "چپ", - right: "راست", - front: "جلو", - back: "عقب", - top: "بالا", - }; - const sideFa = side ? sideFaMap[side] : undefined; + const damagedParts = selectedNorm.map((sp) => { + const catalogMeta = sp.catalogKey + ? catalogByKey.get(sp.catalogKey) + : sp.name + ? catalogByKey.get(catalogLikeKeyFromPart(sp)) + : undefined; + const labelEnName = sp.name + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); + const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); return { - id: selectedMeta?.id ?? catalogMeta?.id, - key: partKey, - side, - label_fa: sideFa ? `${label.fa} (${sideFa})` : label.fa, - label_en: label.en, + id: sp.id ?? catalogMeta?.id, + key: sp.name, + name: sp.name, + side: sp.side, + label_fa: sp.label_fa, + label_en: labelEnName, captured: hasDamagedPartCapture( claimCase.media?.damagedParts as any, - partKey, + ck, + selectedNorm, ), }; }); @@ -4835,48 +4855,90 @@ export class ClaimRequestManagementService { }, }; - const selectedBefore = (claimCase.damage?.selectedParts || []) as string[]; + const carType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const selectedOuterLegacy = (claimCase.damage as any)?.selectedOuterParts; + const selectedBeforeNorm = normalizeDamageSelectedParts( + claimCase.damage?.selectedParts, + carType, + selectedOuterLegacy, + ); + let nextSelected = [...selectedBeforeNorm]; + let nextMedia = coerceDamagedPartsMediaToArray( + claimCase.media?.damagedParts, + nextSelected, + ); + if (treatAsAngle && angleCanon) { updateData[`media.carAngles.${angleCanon}`] = captureData; - const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset( - angleCanon, - body.captureKey, - selectedBefore, - ); - if (unsetFieldKeys.length) { - updateData.$unset = {}; - for (const k of unsetFieldKeys) { - updateData.$unset[`media.damagedParts.${k}`] = ""; + if (!Array.isArray(claimCase.media?.damagedParts)) { + const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset( + angleCanon, + body.captureKey, + claimCase.damage?.selectedParts, + carType, + selectedOuterLegacy, + ); + if (unsetFieldKeys.length) { + updateData.$unset = {}; + for (const k of unsetFieldKeys) { + updateData.$unset[`media.damagedParts.${k}`] = ""; + } } } } else { - const canonicalPartKey = resolveCanonicalDamagedPartStorageKey( + let idx = resolvePartCaptureIndex( body.captureKey, - selectedBefore, + nextSelected, + nextMedia, ); - updateData[`media.damagedParts.${canonicalPartKey}`] = captureData; - const raw = String(body.captureKey ?? "").trim(); - const titleLegacy = - legacyDamagedPartTitleEnFromPartKey(canonicalPartKey); - const unsetPartKeys = new Set(); - if (raw && raw !== canonicalPartKey) unsetPartKeys.add(raw); - if (titleLegacy && titleLegacy !== canonicalPartKey) { - unsetPartKeys.add(titleLegacy); + if (isResendCapture && body.captureType === "part" && idx < 0) { + const added = normalizeDamageSelectedParts( + [body.captureKey], + carType, + undefined, + ); + const row = + added[0] || { + id: null, + name: body.captureKey, + side: "", + label_fa: body.captureKey, + catalogKey: body.captureKey, + }; + nextSelected.push(row); + idx = nextSelected.length - 1; + nextMedia.push({}); } - if (unsetPartKeys.size) { - updateData.$unset = updateData.$unset || {}; - for (const k of unsetPartKeys) { - updateData.$unset[`media.damagedParts.${k}`] = ""; - } + if (idx < 0) { + throw new BadRequestException( + `Unknown damaged-part captureKey "${body.captureKey}". Use catalog id, 0-based index, or catalog key.`, + ); + } + while (nextMedia.length <= idx) { + nextMedia.push({}); + } + const base = nextSelected[idx] || { + id: null, + name: String(body.captureKey), + side: "", + label_fa: String(body.captureKey), + }; + nextMedia[idx] = { + ...(base.id != null ? { id: base.id } : {}), + name: base.name, + side: base.side, + label_fa: base.label_fa, + ...(base.catalogKey ? { catalogKey: base.catalogKey } : {}), + ...nextMedia[idx], + ...captureData, + }; + updateData["media.damagedParts"] = nextMedia; + if ( + isResendCapture && + nextSelected.length !== selectedBeforeNorm.length + ) { + updateData["damage.selectedParts"] = nextSelected; } - } - - if ( - isResendCapture && - body.captureType === "part" && - !selectedBefore.includes(body.captureKey) - ) { - updateData.$addToSet = { "damage.selectedParts": body.captureKey }; } const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( @@ -4911,10 +4973,14 @@ export class ClaimRequestManagementService { data && (data instanceof Map ? data.get(key) : data[key]); // Use same logic as getCaptureRequirementsV2 to determine completion - const anglesKeys = ['front', 'back', 'left', 'right']; + const anglesKeys = ["front", "back", "left", "right"]; const carAnglesData = updatedClaim?.media?.carAngles as any; const damagedPartsData = updatedClaim?.media?.damagedParts as any; - const selectedParts = updatedClaim?.damage?.selectedParts || []; + const selectedNormAfter = normalizeDamageSelectedParts( + updatedClaim?.damage?.selectedParts, + updatedClaim?.vehicle?.carType as ClaimVehicleTypeV2, + (updatedClaim?.damage as any)?.selectedOuterParts, + ); const anglesCaptured = anglesKeys.filter((k) => hasClaimCarAngleCapture( @@ -4923,12 +4989,17 @@ export class ClaimRequestManagementService { k as ClaimCarAngleKey, ), ).length; - const partsCaptured = selectedParts.filter((p) => - hasDamagedPartCapture(damagedPartsData, p), - ).length; + const partsCaptured = selectedNormAfter.filter((sp) => { + const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); + return hasDamagedPartCapture( + damagedPartsData, + ck, + selectedNormAfter, + ); + }).length; const allCapturesComplete = - anglesCaptured >= 4 && partsCaptured >= selectedParts.length; + anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length; if (allCapturesComplete) { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { @@ -4955,7 +5026,8 @@ export class ClaimRequestManagementService { }); } - const remaining = (4 - anglesCaptured) + (selectedParts.length - partsCaptured); + const remaining = + (4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured); const message = allCapturesComplete ? 'All captures complete. Please proceed to upload required documents.' : `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`; @@ -5193,11 +5265,29 @@ export class ClaimRequestManagementService { side: p.side, })); - const mergedSelected = [...(claimCase.damage?.selectedParts ?? [])]; + const objectionCarType = claimCase.vehicle?.carType as + | ClaimVehicleTypeV2 + | undefined; + const mergedNorm = normalizeDamageSelectedParts( + claimCase.damage?.selectedParts, + objectionCarType, + (claimCase.damage as any)?.selectedOuterParts, + ); + const mergedKeys = new Set(mergedNorm.map((p) => partIdentityKey(p))); for (const np of newParts) { - const key = np.partName?.trim(); - if (key && !mergedSelected.includes(key)) { - mergedSelected.push(key); + const name = np.partName?.trim(); + if (!name) continue; + const side = String(np.side ?? "").trim(); + const row = { + id: null as number | null, + name, + side, + label_fa: name, + }; + const k = partIdentityKey(row); + if (!mergedKeys.has(k)) { + mergedNorm.push(row); + mergedKeys.add(k); } } @@ -5215,7 +5305,7 @@ export class ClaimRequestManagementService { 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, 'workflow.nextStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION, 'evaluation.objection': objectionPayload, - 'damage.selectedParts': mergedSelected, + 'damage.selectedParts': mergedNorm, }, $unset: { 'evaluation.ownerPricedPartsApproval': "", @@ -5695,47 +5785,56 @@ export class ClaimRequestManagementService { }; } - const getPartLabelFa = (key: string): string => { - const labels: Record = { - hood: 'کاپوت', - trunk: 'صندوق عقب', - roof: 'سقف', - front_right_door: 'درب جلو راست', - front_left_door: 'درب جلو چپ', - rear_right_door: 'درب عقب راست', - rear_left_door: 'درب عقب چپ', - front_bumper: 'سپر جلو', - rear_bumper: 'سپر عقب', - front_right_fender: 'گلگیر جلو راست', - front_left_fender: 'گلگیر جلو چپ', - rear_right_fender: 'گلگیر عقب راست', - rear_left_fender: 'گلگیر عقب چپ', - }; - return labels[key] || key; - }; - + const carTypeDetails = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const selectedNormDetails = normalizeDamageSelectedParts( + claim.damage?.selectedParts, + carTypeDetails, + (claim.damage as any)?.selectedOuterParts, + ); const damagedPartsData = claim.media?.damagedParts as any; - const damagedParts: Record< - string, - { label_fa: string; captured: boolean; url?: string } - > = {}; const resendPartKeys = normalizeResendPartKeys( claim.evaluation?.damageExpertResend?.resendCarParts, ); - const partKeysForDetails = new Set([ - ...((claim.damage?.selectedParts || []) as string[]), - ...resendPartKeys, - ]); - for (const p of partKeysForDetails) { - const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as - | { url?: string; path?: string } - | undefined; - damagedParts[p] = { - label_fa: getPartLabelFa(p), + const displayParts = [...selectedNormDetails]; + const seenDetailKeys = new Set( + selectedNormDetails.map((p) => partIdentityKey(p)), + ); + for (const rk of resendPartKeys) { + const extra = + normalizeDamageSelectedParts([rk], carTypeDetails, undefined)[0] || + { + id: null, + name: rk, + side: "", + label_fa: rk, + catalogKey: rk, + }; + const k = partIdentityKey(extra); + if (!seenDetailKeys.has(k)) { + displayParts.push(extra); + seenDetailKeys.add(k); + } + } + + const damagedParts = displayParts.map((sp, index) => { + const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); + const cap = getDamagedPartCaptureBlob( + damagedPartsData, + ck, + selectedNormDetails, + ) as { url?: string; path?: string; fileName?: string } | undefined; + return { + index, + id: sp.id, + name: sp.name, + side: sp.side, + label_fa: sp.label_fa, captured: !!cap, url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), + ...(cap?.path ? { path: cap.path } : {}), + ...(cap?.fileName ? { fileName: cap.fileName } : {}), }; - } + }); const er = claim.evaluation?.damageExpertResend; const expertResend = @@ -5776,7 +5875,10 @@ export class ClaimRequestManagementService { } : undefined; - const mappedEvaluation = await this.mapEvaluationForClient(claim.evaluation); + const mappedEvaluation = await this.mapEvaluationForClient( + claim.evaluation, + claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined, + ); return { claimRequestId: claim._id.toString(), @@ -5790,7 +5892,7 @@ export class ClaimRequestManagementService { blameRequestNo: claim.blameRequestNo, ...(ownerData ? { owner: ownerData } : {}), vehicle: claim.vehicle, - selectedParts: claim.damage?.selectedParts, + selectedParts: selectedNormDetails, otherParts: claim.damage?.otherParts, money: isExpertViewer ? moneyForExpert : moneyForUser, requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined, diff --git a/src/claim-request-management/dto/capture-part-v2.dto.ts b/src/claim-request-management/dto/capture-part-v2.dto.ts index 5e005e2..12ef6f6 100644 --- a/src/claim-request-management/dto/capture-part-v2.dto.ts +++ b/src/claim-request-management/dto/capture-part-v2.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; /** * V2 DTO for capturing car angle or damaged part @@ -18,7 +17,8 @@ export class CapturePartV2Dto { captureType: 'angle' | 'part'; @ApiProperty({ - description: 'Key of the angle or part being captured', + description: + 'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.', example: 'front', }) @IsNotEmpty({ message: 'Capture key is required' }) 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 e118d0f..b2b2d90 100644 --- a/src/claim-request-management/dto/capture-requirements-v2.dto.ts +++ b/src/claim-request-management/dto/capture-requirements-v2.dto.ts @@ -72,10 +72,17 @@ export class CarAngleItem { */ export class DamagedPartItem { @ApiProperty({ - description: 'Part key', - example: 'hood', + description: + 'Side-agnostic part name (matches catalog suffix); use with `side` to disambiguate', + example: 'backfender', }) - key: string; + name: string; + + @ApiPropertyOptional({ + description: 'Vehicle side / region (left, right, front, back, top)', + example: 'left', + }) + side?: string; @ApiProperty({ description: 'Display label in Farsi', @@ -83,6 +90,12 @@ export class DamagedPartItem { }) label_fa: string; + @ApiPropertyOptional({ + description: 'Deprecated: same as `name` (kept for older clients)', + example: 'backfender', + }) + key?: string; + @ApiProperty({ description: 'Display label in English', example: 'Hood', 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 51c7676..1017d6d 100644 --- a/src/claim-request-management/dto/claim-details-v2.dto.ts +++ b/src/claim-request-management/dto/claim-details-v2.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DamageSelectedPartV2BodyDto } from './damage-selected-part-v2.dto'; /** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */ export class ClaimDetailsOwnerNextActionV2Dto { @@ -123,8 +124,11 @@ export class ClaimDetailsV2ResponseDto { plate?: any; }; - @ApiPropertyOptional({ description: 'Selected outer damaged parts' }) - selectedParts?: string[]; + @ApiPropertyOptional({ + description: 'Selected outer damaged parts (ordered objects with id, name, side, label_fa)', + type: [DamageSelectedPartV2BodyDto], + }) + selectedParts?: DamageSelectedPartV2BodyDto[]; @ApiPropertyOptional({ description: 'Selected other damaged parts' }) otherParts?: string[]; @@ -141,8 +145,36 @@ export class ClaimDetailsV2ResponseDto { @ApiPropertyOptional({ description: 'Car angles captured' }) carAngles?: Record; - @ApiPropertyOptional({ description: 'Damaged parts captured' }) - damagedParts?: Record; + @ApiPropertyOptional({ + description: + 'Per-part capture status and URLs (array index aligns with selectedParts; includes id, name, side, label_fa)', + type: 'array', + items: { + type: 'object', + properties: { + index: { type: 'number' }, + id: { type: 'number', nullable: true }, + name: { type: 'string' }, + side: { type: 'string' }, + label_fa: { type: 'string' }, + captured: { type: 'boolean' }, + url: { type: 'string' }, + path: { type: 'string' }, + fileName: { type: 'string' }, + }, + }, + }) + damagedParts?: Array<{ + index: number; + id?: number | null; + name: string; + side: string; + label_fa: string; + captured: boolean; + url?: string; + path?: string; + fileName?: string; + }>; @ApiPropertyOptional({ description: diff --git a/src/claim-request-management/dto/damage-selected-part-v2.dto.ts b/src/claim-request-management/dto/damage-selected-part-v2.dto.ts new file mode 100644 index 0000000..0a189c8 --- /dev/null +++ b/src/claim-request-management/dto/damage-selected-part-v2.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsInt, IsOptional, IsString } from "class-validator"; + +/** Stored shape / API payload for one selected outer damaged part (V2). */ +export class DamageSelectedPartV2BodyDto { + @ApiPropertyOptional({ description: "Catalog id when from outer-parts catalog" }) + @IsOptional() + @IsInt() + id?: number; + + @ApiProperty({ example: "backfender" }) + @IsString() + name: string; + + @ApiProperty({ example: "left" }) + @IsString() + side: string; + + @ApiProperty({ example: "گلگیر عقب" }) + @IsString() + label_fa: string; + + @ApiPropertyOptional({ description: "Original catalog key, e.g. left_backfender" }) + @IsOptional() + @IsString() + catalogKey?: string; +} 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 3f8a1e8..a4f7236 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 @@ -4,6 +4,7 @@ import { ClaimVehicleTypeV2, OuterPartSideV2, } from "src/static/outer-car-parts-catalog"; +import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto"; /** * Enum for valid outer car parts that can be damaged @@ -97,10 +98,11 @@ export class SelectOuterPartsV2ResponseDto { publicId: string; @ApiProperty({ - description: 'Array of selected damaged parts', - example: ['hood', 'front_right_door', 'rear_bumper'], + description: + "Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)", + type: [DamageSelectedPartV2BodyDto], }) - selectedParts: string[]; + selectedParts: DamageSelectedPartV2BodyDto[]; @ApiProperty({ description: 'Selected part IDs', diff --git a/src/claim-request-management/entites/schema/claim-case.damage.schema.ts b/src/claim-request-management/entites/schema/claim-case.damage.schema.ts index 4065bbd..0c98b00 100644 --- a/src/claim-request-management/entites/schema/claim-case.damage.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.damage.schema.ts @@ -1,32 +1,15 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Schema as MongooseSchema } from "mongoose"; import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema"; -@Schema({ _id: false }) -export class SelectedOuterPartV2 { - @Prop({ type: Number }) - id: number; - - @Prop({ type: String }) - key: string; - - @Prop({ type: String }) - side: string; -} -export const SelectedOuterPartV2Schema = - SchemaFactory.createForClass(SelectedOuterPartV2); - @Schema({ _id: false }) export class ClaimDamageSelection { /** - * V2: Array of selected damaged outer car part names - * Examples: ['hood', 'front_right_door', 'rear_bumper'] + * V2: Selected outer damaged parts (ordered). Prefer objects + * `{ id, name, side, label_fa, catalogKey? }`; legacy string keys are still accepted until migrated. */ - @Prop({ type: [String], default: [] }) - selectedParts?: string[]; - - /** Structured selected outer parts with id + side for better downstream handling. */ - @Prop({ type: [SelectedOuterPartV2Schema], default: [] }) - selectedOuterParts?: SelectedOuterPartV2[]; + @Prop({ type: [MongooseSchema.Types.Mixed], default: [] }) + selectedParts?: unknown[]; /** * V2: Array of selected other (non-body) damaged parts @@ -46,4 +29,3 @@ export class ClaimDamageSelection { } export const ClaimDamageSelectionSchema = SchemaFactory.createForClass(ClaimDamageSelection); - 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 a757237..91ba7d4 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 @@ -13,11 +13,12 @@ export class ClaimPartPricing { partId: string; /** - * Legacy string or structured `{ part, side }` from expert reply DTOs (V1/V2). + * Unified outer-part snapshot `{ id?, name, side, label_fa, catalogKey? }` (same as + * `damage.selectedParts` / capture). Legacy: string or `{ part?, side? }` (expert UI). * Must be Mixed — objects cannot cast to String. */ @Prop({ type: MongooseSchema.Types.Mixed }) - carPartDamage?: string | { part?: string; side?: string }; + carPartDamage?: unknown; @Prop({ type: String }) typeOfDamage?: string; diff --git a/src/claim-request-management/entites/schema/claim-case.media.schema.ts b/src/claim-request-management/entites/schema/claim-case.media.schema.ts index 7dced36..fd3186d 100644 --- a/src/claim-request-management/entites/schema/claim-case.media.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.media.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { Types } from "mongoose"; +import { Schema as MongooseSchema, Types } from "mongoose"; import { AiImagesModel } from "./ai-image.schema"; import { CarGreenCardModel } from "./car-green-card.schema"; import { ImageRequiredModel } from "./image-required.schema"; @@ -20,6 +20,39 @@ export class CapturedImage { } export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage); +/** One row per selected damaged part (index-aligned with `damage.selectedParts`). */ +@Schema({ _id: false }) +export class DamagedPartMediaV2Row { + @Prop({ type: Number }) + id?: number; + + @Prop({ type: String }) + name?: string; + + @Prop({ type: String }) + side?: string; + + @Prop({ type: String }) + label_fa?: string; + + @Prop({ type: String }) + catalogKey?: string; + + @Prop({ type: String }) + path?: string; + + @Prop({ type: String }) + fileName?: string; + + @Prop({ type: String }) + url?: string; + + @Prop({ type: Date }) + capturedAt?: Date; +} +export const DamagedPartMediaV2RowSchema = + SchemaFactory.createForClass(DamagedPartMediaV2Row); + @Schema({ _id: false }) export class ClaimMedia { @Prop({ type: CarGreenCardModel }) @@ -46,15 +79,10 @@ export class ClaimMedia { carAngles?: Map; /** - * V2: Damaged parts captures - * Map of part key to captured image + * V2: Damaged parts captures — prefer an array (index matches `damage.selectedParts`). + * Legacy documents may store a plain object map keyed by part slug until migrated on write. */ - @Prop({ - type: Map, - of: CapturedImageSchema, - default: () => ({}), - }) - damagedParts?: Map; + @Prop({ type: MongooseSchema.Types.Mixed }) + damagedParts?: DamagedPartMediaV2Row[] | Record; } export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia); - diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index b02b672..29ad993 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum'; +import { DamageSelectedPartV2BodyDto } from 'src/claim-request-management/dto/damage-selected-part-v2.dto'; export class ClaimDetailV2ResponseDto { @ApiProperty() @@ -64,8 +65,11 @@ export class ClaimDetailV2ResponseDto { @ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' }) blameRequestNo?: string; - @ApiPropertyOptional({ description: 'Selected outer damaged parts' }) - selectedParts?: string[]; + @ApiPropertyOptional({ + description: 'Selected outer damaged parts (id, name, side, label_fa)', + type: [DamageSelectedPartV2BodyDto], + }) + selectedParts?: DamageSelectedPartV2BodyDto[]; @ApiPropertyOptional({ description: 'Selected other damaged parts' }) otherParts?: string[]; @@ -81,8 +85,32 @@ export class ClaimDetailV2ResponseDto { @ApiPropertyOptional({ description: 'Car angles captured' }) carAngles?: Record; - @ApiPropertyOptional({ description: 'Damaged parts captured' }) - damagedParts?: Record; + @ApiPropertyOptional({ + description: + 'Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)', + type: 'array', + items: { + type: 'object', + properties: { + index: { type: 'number' }, + id: { type: 'number', nullable: true }, + name: { type: 'string' }, + side: { type: 'string' }, + label_fa: { type: 'string' }, + captured: { type: 'boolean' }, + url: { type: 'string' }, + }, + }, + }) + damagedParts?: Array<{ + index: number; + id?: number | null; + name: string; + side: string; + label_fa: string; + captured: boolean; + url?: string; + }>; @ApiPropertyOptional({ description: diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index e8c0c5c..16c3ca8 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -7,20 +7,57 @@ import { IsOptional, ValidateNested, IsEnum, + IsInt, } from 'class-validator'; import { Type } from 'class-transformer'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; -export class CarPartDamageV2Dto { - @ApiProperty({ example: 'left' }) - @IsString() - side: string; +/** + * Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows. + * Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }` + * (e.g. part `backFender`, side `left`); the API persists canonical `{ id, name, side, label_fa, catalogKey? }`. + */ +export class ExpertReplyCarPartDamageV2Dto { + @ApiPropertyOptional({ description: 'Catalog id when known' }) + @IsOptional() + @IsInt() + id?: number; - @ApiProperty({ example: 'front_door' }) + @ApiPropertyOptional({ + description: 'Side-agnostic catalog segment, e.g. backfender', + example: 'backfender', + }) + @IsOptional() @IsString() - part: string; + name?: string; + + @ApiPropertyOptional({ example: 'left' }) + @IsOptional() + @IsString() + side?: string; + + @ApiPropertyOptional({ + description: + 'Farsi label; optional when server can resolve from catalog + vehicle.carType', + }) + @IsOptional() + @IsString() + label_fa?: string; + + @ApiPropertyOptional({ example: 'left_backfender' }) + @IsOptional() + @IsString() + catalogKey?: string; + + @ApiPropertyOptional({ + description: 'Legacy expert UI: camelCase segment with `side`, e.g. backFender', + example: 'backFender', + }) + @IsOptional() + @IsString() + part?: string; } /** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */ @@ -54,10 +91,10 @@ export class PartPricingV2Dto { @IsNotEmpty() partId: string; - @ApiProperty({ type: CarPartDamageV2Dto }) + @ApiProperty({ type: ExpertReplyCarPartDamageV2Dto }) @ValidateNested() - @Type(() => CarPartDamageV2Dto) - carPartDamage: CarPartDamageV2Dto; + @Type(() => ExpertReplyCarPartDamageV2Dto) + carPartDamage: ExpertReplyCarPartDamageV2Dto; @ApiProperty({ example: 'Minor' }) @IsString() diff --git a/src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts b/src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts index 1e01288..11d6aad 100644 --- a/src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts +++ b/src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts @@ -1,25 +1,20 @@ import { ApiProperty } from "@nestjs/swagger"; import { ArrayMinSize, - ArrayUnique, IsArray, - IsEnum, - IsNotEmpty, + ValidateNested, } from "class-validator"; -import { OuterCarPart } from "src/claim-request-management/dto/select-outer-parts-v2.dto"; +import { Type } from "class-transformer"; +import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto"; export class UpdateClaimDamagedPartsV2Dto { @ApiProperty({ - description: "Updated list of selected damaged outer parts", - enum: OuterCarPart, - isArray: true, - example: ["hood", "front_bumper", "front_left_fender"], + type: [DamageSelectedPartV2BodyDto], + description: "Full replacement list of selected damaged outer parts (id, name, side, label_fa)", }) - @IsNotEmpty() @IsArray() @ArrayMinSize(1) - @ArrayUnique() - @IsEnum(OuterCarPart, { each: true }) - selectedParts: OuterCarPart[]; + @ValidateNested({ each: true }) + @Type(() => DamageSelectedPartV2BodyDto) + selectedParts: DamageSelectedPartV2BodyDto[]; } - diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 369fec2..6f57538 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -73,8 +73,16 @@ import { import { getClaimCarAngleCaptureBlob, getDamagedPartCaptureBlob, + hasDamagedPartCapture, type ClaimCarAngleKey, } from "src/helpers/claim-car-angle-media"; +import { + catalogLikeKeyFromPart, + coerceDamagedPartsMediaToArray, + normalizeCarPartDamageForExpertReply, + normalizeDamageSelectedParts, +} from "src/helpers/outer-damage-parts"; +import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; @@ -2530,10 +2538,27 @@ export class ExpertClaimService { }); } - const processedParts = + const carTypeSubmit = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const daghiNormalized = reply.parts?.length > 0 ? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts) : []; + const processedParts = daghiNormalized.map((p) => { + let carPartDamage: Record; + try { + carPartDamage = normalizeCarPartDamageForExpertReply( + p.carPartDamage as unknown, + carTypeSubmit, + ); + } catch (err) { + throw new BadRequestException( + `Invalid carPartDamage for part ${p.partId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + return { ...p, carPartDamage }; + }); const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts( processedParts, @@ -3319,40 +3344,35 @@ export class ExpertClaimService { }; } - // Build damaged parts map - const getPartLabelFa = (key: string): string => { - const labels: Record = { - hood: "کاپوت", - trunk: "صندوق عقب", - roof: "سقف", - front_right_door: "درب جلو راست", - front_left_door: "درب جلو چپ", - rear_right_door: "درب عقب راست", - rear_left_door: "درب عقب چپ", - front_bumper: "سپر جلو", - rear_bumper: "سپر عقب", - front_right_fender: "گلگیر جلو راست", - front_left_fender: "گلگیر جلو چپ", - rear_right_fender: "گلگیر عقب راست", - rear_left_fender: "گلگیر عقب چپ", - }; - return labels[key] || key; - }; - const damagedPartsData = claim.media?.damagedParts as any; - const damagedParts: Record< - string, - { label_fa: string; captured: boolean; url?: string } - > = {}; - for (const p of claim.damage?.selectedParts || []) { - const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as - | { url?: string; path?: string } - | undefined; - damagedParts[p] = { - label_fa: getPartLabelFa(p), - captured: !!cap, + // Build damaged parts list (aligned with normalized selected parts) + const carTypeExpert = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const selectedNormExpert = normalizeDamageSelectedParts( + claim.damage?.selectedParts, + carTypeExpert, + (claim.damage as any)?.selectedOuterParts, + ); + const damagedPartsDataExpert = claim.media?.damagedParts as any; + const damagedParts = selectedNormExpert.map((sp, index) => { + const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp); + const cap = getDamagedPartCaptureBlob( + damagedPartsDataExpert, + ck, + selectedNormExpert, + ) as { url?: string; path?: string } | undefined; + return { + index, + id: sp.id, + name: sp.name, + side: sp.side, + label_fa: sp.label_fa, + captured: hasDamagedPartCapture( + damagedPartsDataExpert, + ck, + selectedNormExpert, + ), url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), }; - } + }); // --- Combine both branches of the conflict, preserving necessary logic from both --- @@ -3482,7 +3502,7 @@ export class ExpertClaimService { blameRequestId: claim.blameRequestId?.toString(), blameRequestNo: claim.blameRequestNo, money: moneyPayload, - selectedParts: claim.damage?.selectedParts, + selectedParts: selectedNormExpert, otherParts: claim.damage?.otherParts, requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 @@ -3537,35 +3557,70 @@ export class ExpertClaimService { const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub); + const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const previousNorm = normalizeDamageSelectedParts( + (claim as any).damage?.selectedParts, + carType, + (claim as any).damage?.selectedOuterParts, + ); + const prevMedia = coerceDamagedPartsMediaToArray( + (claim as any).media?.damagedParts, + previousNorm, + ); + + const nextNorm = body.selectedParts.map((p) => ({ + id: p.id ?? null, + name: String(p.name || "").trim(), + side: String(p.side || ""), + label_fa: String(p.label_fa || "").trim() || String(p.name || "").trim(), + ...(p.catalogKey?.trim() ? { catalogKey: p.catalogKey.trim() } : {}), + })); + + const matchPreviousIndex = ( + next: (typeof nextNorm)[0], + previous: typeof previousNorm, + ): number => { + if (next.id != null) { + const byId = previous.findIndex((x) => x.id === next.id); + if (byId >= 0) return byId; + } + if (next.catalogKey) { + const byCk = previous.findIndex((x) => x.catalogKey === next.catalogKey); + if (byCk >= 0) return byCk; + } + return previous.findIndex( + (x) => x.name === next.name && x.side === next.side, + ); + }; + + const nextMedia = nextNorm.map((sp) => { + const j = matchPreviousIndex(sp, previousNorm); + const row = + j >= 0 && prevMedia[j] && typeof prevMedia[j] === "object" + ? (prevMedia[j] as Record) + : {}; + return { + ...(sp.id != null ? { id: sp.id } : {}), + name: sp.name, + side: sp.side, + label_fa: sp.label_fa, + ...(sp.catalogKey ? { catalogKey: sp.catalogKey } : {}), + ...(row.path ? { path: row.path } : {}), + ...(row.fileName ? { fileName: row.fileName } : {}), + ...(row.url ? { url: row.url } : {}), + ...(row.capturedAt ? { capturedAt: row.capturedAt } : {}), + }; + }); + const previous = Array.isArray((claim as any).damage?.selectedParts) ? [...(claim as any).damage.selectedParts] : []; - const selectedParts = body.selectedParts as string[]; const $set: Record = { - "damage.selectedParts": selectedParts, + "damage.selectedParts": nextNorm, + "media.damagedParts": nextMedia, }; - const damagedPartsMedia = (claim as any).media?.damagedParts; - if (damagedPartsMedia) { - const selectedSet = new Set(selectedParts); - if (damagedPartsMedia instanceof Map) { - const m = new Map(damagedPartsMedia as Map); - for (const key of Array.from(m.keys())) { - if (!selectedSet.has(key)) m.delete(key); - } - $set["media.damagedParts"] = Object.fromEntries(m.entries()); - } else { - const o = { - ...(damagedPartsMedia as Record), - }; - for (const key of Object.keys(o)) { - if (!selectedSet.has(key)) delete o[key]; - } - $set["media.damagedParts"] = o; - } - } - await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set, $push: { @@ -3579,7 +3634,7 @@ export class ExpertClaimService { timestamp: new Date(), metadata: { previousSelectedParts: previous, - selectedParts, + selectedParts: nextNorm, ...(damagedPartsEditSnapshot && { expertProfileSnapshot: damagedPartsEditSnapshot, }), @@ -3590,7 +3645,7 @@ export class ExpertClaimService { return { claimRequestId: claim._id.toString(), - selectedParts, + selectedParts: nextNorm, previousSelectedParts: previous, message: "Damaged parts updated successfully.", }; diff --git a/src/helpers/claim-car-angle-media.ts b/src/helpers/claim-car-angle-media.ts index de79122..51f1039 100644 --- a/src/helpers/claim-car-angle-media.ts +++ b/src/helpers/claim-car-angle-media.ts @@ -5,6 +5,13 @@ * into {@code media.damagedParts} and broke step-completion checks. */ +import type { DamageSelectedPartV2 } from "src/helpers/outer-damage-parts"; +import { + damageSelectedPartsToLookupStrings, + mediaRowHasCapture, + resolvePartCaptureIndex, +} from "src/helpers/outer-damage-parts"; + export const CLAIM_CAR_ANGLE_KEYS = ["front", "back", "left", "right"] as const; export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number]; @@ -54,9 +61,17 @@ export function hasClaimCarAngleCapture( export function legacyAngleDamagedPartFieldKeysToUnset( canonical: ClaimCarAngleKey, rawCaptureKey: string, - selectedParts: string[], + selectedParts: unknown, + carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2, + selectedOuterPartsLegacy?: unknown, ): string[] { - const selected = new Set((selectedParts ?? []).map((p) => String(p))); + const selected = new Set( + damageSelectedPartsToLookupStrings( + selectedParts, + carType, + selectedOuterPartsLegacy, + ), + ); const variants = new Set( [ rawCaptureKey, @@ -89,9 +104,23 @@ function normalizeDamagedPartKeyLoose(s: string): string { export function getDamagedPartCaptureBlob( damagedPartsData: unknown, partKey: string, + selectedNormalized?: DamageSelectedPartV2[], ): unknown { const pk = String(partKey ?? "").trim(); if (!pk || damagedPartsData == null) return undefined; + if (Array.isArray(damagedPartsData)) { + const norm = selectedNormalized ?? []; + const idx = resolvePartCaptureIndex( + pk, + norm, + damagedPartsData as Record[], + ); + if (idx >= 0) { + const row = (damagedPartsData as unknown[])[idx]; + if (mediaRowHasCapture(row)) return row; + } + return undefined; + } let b = getCaptureBlob(damagedPartsData, pk); if (b) return b; const titleEn = legacyDamagedPartTitleEnFromPartKey(pk); @@ -120,8 +149,12 @@ export function getDamagedPartCaptureBlob( export function hasDamagedPartCapture( damagedPartsData: unknown, partKey: string, + selectedNormalized?: DamageSelectedPartV2[], ): boolean { - return getDamagedPartCaptureBlob(damagedPartsData, partKey) != null; + return ( + getDamagedPartCaptureBlob(damagedPartsData, partKey, selectedNormalized) != + null + ); } /** @@ -129,11 +162,17 @@ export function hasDamagedPartCapture( */ export function resolveCanonicalDamagedPartStorageKey( captureKeyRaw: string, - selectedParts: string[], + selectedParts: unknown, + carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2, + selectedOuterPartsLegacy?: unknown, ): string { const raw = String(captureKeyRaw ?? "").trim(); if (!raw) return raw; - const parts = (selectedParts ?? []).map((p) => String(p).trim()).filter(Boolean); + const parts = damageSelectedPartsToLookupStrings( + selectedParts, + carType, + selectedOuterPartsLegacy, + ); if (parts.includes(raw)) return raw; const n = normalizeDamagedPartKeyLoose(raw); const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n); diff --git a/src/helpers/claim-expert-resend.ts b/src/helpers/claim-expert-resend.ts index 5d55773..0bbe23a 100644 --- a/src/helpers/claim-expert-resend.ts +++ b/src/helpers/claim-expert-resend.ts @@ -1,5 +1,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; +import { getDamagedPartCaptureBlob } from "./claim-car-angle-media"; +import { normalizeDamageSelectedParts } from "./outer-damage-parts"; +import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys"; export function resendRequestHasPayload(r: { @@ -59,8 +62,12 @@ export function isRequiredDocumentUploaded(claim: any, documentKey: string): boo export function hasDamagedPartCapture(claim: any, partKey: string): boolean { const data = claim?.media?.damagedParts; if (!data) return false; - const cap = data instanceof Map ? data.get(partKey) : data[partKey]; - return !!cap; + const norm = normalizeDamageSelectedParts( + claim?.damage?.selectedParts, + claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined, + claim?.damage?.selectedOuterParts, + ); + return getDamagedPartCaptureBlob(data, partKey, norm) != null; } /** diff --git a/src/helpers/outer-damage-parts.ts b/src/helpers/outer-damage-parts.ts new file mode 100644 index 0000000..fe904dd --- /dev/null +++ b/src/helpers/outer-damage-parts.ts @@ -0,0 +1,700 @@ +import { + ClaimVehicleTypeV2, + OUTER_PARTS_BY_CAR_TYPE, + OuterPartCatalogItem, + OuterPartSideV2, +} from "src/static/outer-car-parts-catalog"; + +/** Stored on `damage.selectedParts` and used when resolving captures / APIs. */ +export type DamageSelectedPartV2 = { + id: number | null; + name: string; + side: string; + label_fa: string; + /** Original catalog key when known (e.g. `left_backfender`). */ + catalogKey?: string; +}; + +const SIDE_SET = new Set(Object.values(OuterPartSideV2)); + +/** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */ +const SIDE_LABEL_FA: Record = { + [OuterPartSideV2.LEFT]: "چپ", + [OuterPartSideV2.RIGHT]: "راست", + [OuterPartSideV2.FRONT]: "جلو", + [OuterPartSideV2.BACK]: "عقب", + [OuterPartSideV2.TOP]: "بالا", +}; + +function outerCatalogListForItem(item: OuterPartCatalogItem): OuterPartCatalogItem[] { + for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { + if (list.some((x) => x.key === item.key || x.id === item.id)) { + return list; + } + } + return [item]; +} + +/** + * When multiple catalog rows share the same `titleFa` (e.g. left/right rear fender), + * append ` (side)` in Farsi so clients can display `label_fa` alone. + */ +export function disambiguateOuterPartLabelFa( + baseTitleFa: string, + side: string, + sameTypeCatalog: OuterPartCatalogItem[], +): string { + const t = String(baseTitleFa ?? "").trim(); + const s = String(side ?? "").trim(); + if (!t || !sameTypeCatalog?.length) return t; + const norm = (x: string) => x.trim(); + const siblings = sameTypeCatalog.filter((p) => norm(p.titleFa) === norm(t)); + if (siblings.length <= 1) return t; + const sideFa = SIDE_LABEL_FA[s] || s; + if (!sideFa) return t; + return `${t} (${sideFa})`; +} + +const CATALOG_ITEM_BY_ID = new Map(); +for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { + for (const it of list) { + CATALOG_ITEM_BY_ID.set(it.id, it); + } +} + +function findCatalogItemByKey(key: string): OuterPartCatalogItem | undefined { + for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { + const hit = list.find((x) => x.key === key); + if (hit) return hit; + } + return undefined; +} + +/** Strip leading `left_|right_|…` when present so `name` is side-agnostic for clients. */ +export function splitCatalogKeyToNameAndSide( + fullKey: string, +): { name: string; side: string } { + const t = String(fullKey ?? "").trim(); + const i = t.indexOf("_"); + if (i <= 0) return { name: t, side: "" }; + const head = t.slice(0, i); + if (SIDE_SET.has(head)) { + return { side: head, name: t.slice(i + 1) }; + } + return { name: t, side: "" }; +} + +export function catalogLikeKeyFromPart( + part: Pick, +): string { + if (part.side) return `${part.side}_${part.name}`; + return part.name; +} + +export function catalogItemToSelectedPart( + item: OuterPartCatalogItem, + sameTypeCatalog?: OuterPartCatalogItem[], +): DamageSelectedPartV2 { + const { name, side } = splitCatalogKeyToNameAndSide(item.key); + const sideEff = String(side || item.side || ""); + const list = sameTypeCatalog ?? outerCatalogListForItem(item); + const label_fa = disambiguateOuterPartLabelFa(item.titleFa, sideEff, list); + return { + id: item.id, + name, + side: sideEff, + label_fa, + catalogKey: item.key, + }; +} + +const LEGACY_SLUG_LABEL_FA: Record = { + hood: "کاپوت", + trunk: "صندوق عقب", + roof: "سقف", + front_right_door: "درب جلو راست", + front_left_door: "درب جلو چپ", + rear_right_door: "درب عقب راست", + rear_left_door: "درب عقب چپ", + front_bumper: "سپر جلو", + rear_bumper: "سپر عقب", + front_right_fender: "گلگیر جلو راست", + front_left_fender: "گلگیر جلو چپ", + rear_right_fender: "گلگیر عقب راست", + rear_left_fender: "گلگیر عقب چپ", +}; + +function labelFaForUnknownSlug(slug: string): string { + return LEGACY_SLUG_LABEL_FA[slug] || slug; +} + +function toNum(v: unknown): number | null { + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** + * Normalize `damage.selectedParts` from DB (strings, legacy objects, or new objects) + * into a stable list for APIs and capture resolution. + */ +export function normalizeDamageSelectedParts( + raw: unknown, + carType?: ClaimVehicleTypeV2, + selectedOuterPartsLegacy?: unknown, +): DamageSelectedPartV2[] { + const arr = Array.isArray(raw) ? raw : []; + const legacyOuter = Array.isArray(selectedOuterPartsLegacy) + ? selectedOuterPartsLegacy + : []; + const byKeyFromLegacy = new Map(); + for (const o of legacyOuter) { + if (o && typeof o === "object" && "key" in (o as object)) { + const rec = o as { key?: string; id?: number; side?: string }; + const k = String(rec.key || "").trim(); + if (k) byKeyFromLegacy.set(k, { id: rec.id, side: rec.side }); + } + } + const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined; + const byCatalogKey = catalog + ? new Map(catalog.map((c) => [c.key, c])) + : undefined; + + const out: DamageSelectedPartV2[] = []; + for (const el of arr) { + if (el == null) continue; + if (typeof el === "string") { + const s = el.trim(); + if (!s) continue; + let catItem = byCatalogKey?.get(s) ?? findCatalogItemByKey(s); + if (!catItem && /^\d+$/.test(s)) { + catItem = CATALOG_ITEM_BY_ID.get(Number(s)); + } + if (catItem) { + const list = catalog ?? outerCatalogListForItem(catItem); + out.push(catalogItemToSelectedPart(catItem, list)); + continue; + } + const hint = byKeyFromLegacy.get(s); + if (hint?.side || hint?.id != null) { + const split = splitCatalogKeyToNameAndSide(s); + if (split.side) { + const sideEff = hint?.side ?? split.side; + const baseFa = labelFaForUnknownSlug(s); + const list = catalog ?? []; + const label_fa = + catalog?.length && baseFa + ? disambiguateOuterPartLabelFa(baseFa, String(sideEff), catalog) + : baseFa; + out.push({ + id: hint?.id ?? null, + name: split.name, + side: sideEff, + label_fa, + catalogKey: s, + }); + continue; + } + } + out.push({ + id: hint?.id ?? null, + name: s, + side: hint?.side ?? "", + label_fa: labelFaForUnknownSlug(s), + catalogKey: s, + }); + continue; + } + if (typeof el === "object") { + const o = el as Record; + if (typeof o.name === "string" && o.name.trim()) { + let name = o.name.trim(); + const inner = splitCatalogKeyToNameAndSide(name); + if (inner.side && inner.name) { + name = inner.name; + } + const side = typeof o.side === "string" ? o.side : inner.side || ""; + const id = toNum(o.id); + const ck = + typeof o.catalogKey === "string" && o.catalogKey.trim() + ? o.catalogKey.trim() + : undefined; + let catItem: OuterPartCatalogItem | undefined; + if (catalog && byCatalogKey) { + if (ck) catItem = byCatalogKey.get(ck); + if (!catItem && id != null) catItem = catalog.find((c) => c.id === id); + if (!catItem && side && name) { + catItem = byCatalogKey.get(catalogLikeKeyFromPart({ side, name })); + } + } else { + if (ck) catItem = findCatalogItemByKey(ck); + if (!catItem && id != null) { + catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined; + } + if (!catItem && side && name) { + catItem = findCatalogItemByKey( + catalogLikeKeyFromPart({ side, name }), + ); + } + } + const fallbackLabel = + typeof o.label_fa === "string" && o.label_fa.trim() + ? o.label_fa.trim() + : typeof o.labelFa === "string" && o.labelFa.trim() + ? (o.labelFa as string).trim() + : name; + const listForDis = catalog?.length + ? catalog + : catItem + ? outerCatalogListForItem(catItem) + : []; + const label_fa = + catItem && listForDis.length + ? disambiguateOuterPartLabelFa( + catItem.titleFa, + catItem.side, + listForDis, + ) + : fallbackLabel; + out.push({ + id, + name, + side, + label_fa, + catalogKey: ck, + }); + continue; + } + if (typeof o.key === "string" && o.key.trim()) { + const k = o.key.trim(); + const cat = + byCatalogKey?.get(k) ?? + findCatalogItemByKey(k) ?? + (toNum(o.id) != null ? CATALOG_ITEM_BY_ID.get(toNum(o.id)!) : undefined); + if (cat) { + const list = catalog ?? outerCatalogListForItem(cat); + out.push(catalogItemToSelectedPart(cat, list)); + continue; + } + const split = splitCatalogKeyToNameAndSide(k); + out.push({ + id: toNum(o.id), + name: split.side ? split.name : k, + side: typeof o.side === "string" ? o.side : split.side, + label_fa: + typeof o.label_fa === "string" && o.label_fa.trim() + ? (o.label_fa as string).trim() + : labelFaForUnknownSlug(k), + catalogKey: k, + }); + } + } + } + return out; +} + +export function partIdentityKey(p: DamageSelectedPartV2): string { + if (p.id != null && Number.isFinite(p.id)) return `id:${p.id}`; + return `${p.side}|${p.name}`; +} + +function normPartSegment(s: string): string { + return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function looseCatalogNameMatch( + catalogItem: OuterPartCatalogItem, + expertPart: string, +): boolean { + const { name } = splitCatalogKeyToNameAndSide(catalogItem.key); + return normPartSegment(name) === normPartSegment(expertPart); +} + +/** + * Map legacy expert reply `{ part: "backFender", side: "left" }` to a catalog row + * when `carType` is known (or scan all types if omitted). + */ +export function findCatalogItemFromLegacyExpertPart( + part: string, + side: string, + carType?: ClaimVehicleTypeV2, +): OuterPartCatalogItem | undefined { + const s = String(side ?? "").toLowerCase().trim(); + const lists = carType + ? [OUTER_PARTS_BY_CAR_TYPE[carType]].filter(Boolean) as OuterPartCatalogItem[][] + : (Object.values(OUTER_PARTS_BY_CAR_TYPE) as OuterPartCatalogItem[][]); + for (const catalog of lists) { + if (!catalog?.length) continue; + for (const it of catalog) { + if (String(it.side).toLowerCase() !== s) continue; + if (looseCatalogNameMatch(it, part)) return it; + } + } + return undefined; +} + +function selectedPartToStoredRecord(p: DamageSelectedPartV2): Record { + const r: Record = { + name: p.name, + side: String(p.side || "").toLowerCase(), + label_fa: p.label_fa, + }; + if (p.id != null && Number.isFinite(p.id)) r.id = p.id; + if (p.catalogKey) r.catalogKey = p.catalogKey; + return r; +} + +/** + * Canonical shape for `evaluation.damageExpertReply*.parts[].carPartDamage` + * (same fields as owner `damage.selectedParts` items). + */ +export function normalizeCarPartDamageForExpertReply( + raw: unknown, + carType?: ClaimVehicleTypeV2, +): Record { + if (raw == null) { + throw new Error("carPartDamage is required"); + } + if (typeof raw === "string") { + const s = raw.trim(); + if (!s) throw new Error("carPartDamage is empty"); + const fromKey = findCatalogItemByKey(s); + if (fromKey) { + const list = outerCatalogListForItem(fromKey); + return selectedPartToStoredRecord( + catalogItemToSelectedPart(fromKey, list), + ); + } + return { name: s, side: "", label_fa: s }; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error("Invalid carPartDamage"); + } + const o = raw as Record; + const legacyPart = + typeof o.part === "string" && o.part.trim() ? o.part.trim() : ""; + const legacySide = + typeof o.side === "string" && o.side.trim() + ? o.side.trim().toLowerCase() + : ""; + const name = typeof o.name === "string" && o.name.trim() ? o.name.trim() : ""; + const sideIn = + typeof o.side === "string" && o.side.trim() + ? o.side.trim().toLowerCase() + : ""; + + if (name && sideIn) { + const id = toNum(o.id); + const ck = + typeof o.catalogKey === "string" && o.catalogKey.trim() + ? o.catalogKey.trim() + : undefined; + const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined; + let catItem: OuterPartCatalogItem | undefined; + if (catalog) { + const byKey = new Map(catalog.map((c) => [c.key, c])); + if (ck) catItem = byKey.get(ck); + if (!catItem && id != null) catItem = catalog.find((c) => c.id === id); + if (!catItem) { + catItem = byKey.get(catalogLikeKeyFromPart({ side: sideIn, name })); + } + } + if (!catItem && id != null) { + catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined; + } + const list = catalog?.length + ? catalog + : catItem + ? outerCatalogListForItem(catItem) + : []; + if (catItem && list.length) { + return selectedPartToStoredRecord( + catalogItemToSelectedPart(catItem, list), + ); + } + const label_fa = + typeof o.label_fa === "string" && o.label_fa.trim() + ? o.label_fa.trim() + : name; + const out: Record = { + name, + side: sideIn, + label_fa, + }; + if (id != null) out.id = id; + if (ck) out.catalogKey = ck; + return out; + } + + if (legacyPart && legacySide) { + const catItem = findCatalogItemFromLegacyExpertPart( + legacyPart, + legacySide, + carType, + ); + if (catItem) { + const list = outerCatalogListForItem(catItem); + return selectedPartToStoredRecord( + catalogItemToSelectedPart(catItem, list), + ); + } + const sideFa = SIDE_LABEL_FA[legacySide] || legacySide; + return { + name: legacyPart, + side: legacySide, + label_fa: `${legacyPart} (${sideFa})`, + }; + } + + throw new Error("carPartDamage must include side and (name or part)"); +} + +/** Best-effort migration for API output; never throws. */ +export function migrateExpertReplyCarPartDamageToUnified( + raw: unknown, + carType?: ClaimVehicleTypeV2, +): Record { + try { + return normalizeCarPartDamageForExpertReply(raw, carType); + } catch { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + return { ...(raw as Record) }; + } + return { name: String(raw ?? ""), side: "", label_fa: String(raw ?? "") }; + } +} + +/** Stable string for factor DB rows / logs from unified or legacy `carPartDamage`. */ +export function carPartDamageLabelForFactorRecord(cd: unknown): string { + if (cd == null) return ""; + if (typeof cd === "string") return cd.trim(); + if (typeof cd === "object") { + const o = cd as Record; + if (typeof o.label_fa === "string" && o.label_fa.trim()) { + return o.label_fa.trim(); + } + if (typeof o.part === "string" && o.part.trim()) { + const side = typeof o.side === "string" ? o.side.trim() : ""; + const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : ""; + return sideFa ? `${o.part.trim()} (${sideFa})` : o.part.trim(); + } + if (typeof o.name === "string" && o.name.trim()) { + const side = typeof o.side === "string" ? o.side.trim() : ""; + const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : ""; + return sideFa ? `${o.name.trim()} (${sideFa})` : o.name.trim(); + } + } + return String(cd); +} + +export function mediaRowHasCapture(row: unknown): boolean { + if (!row || typeof row !== "object") return false; + const r = row as { path?: string; url?: string }; + return !!(r.path || r.url); +} + +/** Whether `media.damagedParts` is the new array shape (possibly empty). */ +export function isDamagedPartsMediaArray(data: unknown): boolean { + return Array.isArray(data); +} + +function getLegacyMapRecord( + data: unknown, +): Record | null { + if (data == null) return null; + if (data instanceof Map) { + return Object.fromEntries((data as Map).entries()); + } + if (typeof data === "object" && !Array.isArray(data)) { + return data as Record; + } + return null; +} + +function getCaptureBlobLoose( + legacyMap: Record, + partKey: string, +): unknown { + const pk = String(partKey ?? "").trim(); + if (!pk) return undefined; + let b = legacyMap[pk]; + if (b) return b; + const target = pk.toLowerCase().replace(/[\s_-]/g, ""); + for (const k of Object.keys(legacyMap)) { + if (k.toLowerCase().replace(/[\s_-]/g, "") === target) { + return legacyMap[k]; + } + } + return undefined; +} + +/** + * Align legacy `media.damagedParts` map with `selected` order → array rows + * (metadata + any existing capture blobs). + */ +export function migrateLegacyDamagedPartsMapToArray( + legacyMap: Record, + selected: DamageSelectedPartV2[], +): Record[] { + return selected.map((sp) => { + const keysTry = [ + sp.catalogKey, + catalogLikeKeyFromPart(sp), + sp.name, + ].filter(Boolean) as string[]; + let blob: unknown; + for (const k of keysTry) { + blob = getCaptureBlobLoose(legacyMap, k); + if (blob) break; + } + const base = { + id: sp.id ?? undefined, + name: sp.name, + side: sp.side, + label_fa: sp.label_fa, + }; + if (blob && typeof blob === "object") { + const c = blob as Record; + return { + ...base, + ...(c.path ? { path: c.path } : {}), + ...(c.fileName ? { fileName: c.fileName } : {}), + ...(c.url ? { url: c.url } : {}), + ...(c.capturedAt ? { capturedAt: c.capturedAt } : {}), + }; + } + return { ...base }; + }); +} + +/** + * Always returns an array view: migrates legacy map → array when needed. + */ +export function coerceDamagedPartsMediaToArray( + damagedPartsData: unknown, + selected: DamageSelectedPartV2[], +): Record[] { + if (Array.isArray(damagedPartsData)) { + const arr = damagedPartsData.map((x) => + typeof x === "object" && x ? { ...(x as object) } : {}, + ) as Record[]; + while (arr.length < selected.length) { + arr.push({}); + } + return arr; + } + const legacy = getLegacyMapRecord(damagedPartsData); + if (legacy && selected.length) { + return migrateLegacyDamagedPartsMapToArray(legacy, selected); + } + return []; +} + +/** + * Resolve `captureKey` to an index into `selected` / `media.damagedParts`. + * + * Supported keys: catalog id (number string), array index when unambiguous, + * full catalog key (`left_backfender`), or `side_name` matching stored part. + */ +export function resolvePartCaptureIndex( + captureKeyRaw: string, + selected: DamageSelectedPartV2[], + damagedPartsArray?: Record[], +): number { + const raw = String(captureKeyRaw ?? "").trim(); + if (!raw) return -1; + + if (selected.length) { + for (let i = 0; i < selected.length; i++) { + const sp = selected[i]; + if (sp.catalogKey === raw) return i; + if (catalogLikeKeyFromPart(sp) === raw) return i; + } + + if (/^\d+$/.test(raw)) { + const n = Number(raw); + const byId = selected.findIndex((p) => p.id === n); + if (byId >= 0) return byId; + const hasIdCollision = selected.some((p) => p.id === n); + if (!hasIdCollision && n >= 0 && n < selected.length) return n; + } + + const loose = raw.toLowerCase().replace(/[\s_-]/g, ""); + for (let i = 0; i < selected.length; i++) { + const sp = selected[i]; + const cand = [ + sp.catalogKey, + catalogLikeKeyFromPart(sp), + sp.name, + ].filter(Boolean) as string[]; + for (const c of cand) { + if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i; + } + } + } + + if (damagedPartsArray && damagedPartsArray.length) { + for (let i = 0; i < damagedPartsArray.length; i++) { + const row = damagedPartsArray[i]; + if (!row || typeof row !== "object") continue; + const id = toNum((row as { id?: unknown }).id); + if (id != null && String(id) === raw) return i; + const ck = String((row as { catalogKey?: unknown }).catalogKey || ""); + if (ck && ck === raw) return i; + const name = String((row as { name?: unknown }).name || ""); + const side = String((row as { side?: unknown }).side || ""); + if (side && name && `${side}_${name}` === raw) return i; + } + } + + return -1; +} + +export function mediaRowOrLegacyHasCapture( + damagedPartsData: unknown, + partKey: string, + selected: DamageSelectedPartV2[], +): boolean { + if (damagedPartsData == null) return false; + if (Array.isArray(damagedPartsData)) { + const idx = resolvePartCaptureIndex(partKey, selected, damagedPartsData as any[]); + if (idx < 0) return false; + return mediaRowHasCapture((damagedPartsData as unknown[])[idx]); + } + const legacy = getLegacyMapRecord(damagedPartsData); + if (!legacy) return false; + return !!getCaptureBlobLoose(legacy, partKey); +} + +export function selectedPartsLegacyStringsForAngles( + selected: DamageSelectedPartV2[], +): string[] { + const s = new Set(); + for (const p of selected) { + if (p.catalogKey) s.add(p.catalogKey); + s.add(catalogLikeKeyFromPart(p)); + if (p.name) s.add(p.name); + } + return [...s]; +} + +/** String keys that might appear under legacy `media.damagedParts` for this selection. */ +export function damageSelectedPartsToLookupStrings( + raw: unknown, + carType?: ClaimVehicleTypeV2, + selectedOuterPartsLegacy?: unknown, +): string[] { + const norm = normalizeDamageSelectedParts( + raw, + carType, + selectedOuterPartsLegacy, + ); + if (norm.length) return selectedPartsLegacyStringsForAngles(norm); + if (Array.isArray(raw)) { + return (raw as unknown[]) + .filter((x): x is string => typeof x === "string") + .map((x) => x.trim()) + .filter(Boolean); + } + return []; +}