1
0
forked from Yara724/api

Merge pull request 'Fixed claim/blame damagedParts' (#55) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#55
This commit is contained in:
2026-05-03 13:56:41 +03:30
16 changed files with 1347 additions and 299 deletions

View File

@@ -97,8 +97,6 @@ import {
hasClaimCarAngleCapture, hasClaimCarAngleCapture,
hasDamagedPartCapture, hasDamagedPartCapture,
legacyAngleDamagedPartFieldKeysToUnset, legacyAngleDamagedPartFieldKeysToUnset,
legacyDamagedPartTitleEnFromPartKey,
resolveCanonicalDamagedPartStorageKey,
type ClaimCarAngleKey, type ClaimCarAngleKey,
} from "src/helpers/claim-car-angle-media"; } from "src/helpers/claim-car-angle-media";
import { import {
@@ -106,6 +104,16 @@ import {
OUTER_PARTS_BY_CAR_TYPE, OUTER_PARTS_BY_CAR_TYPE,
OuterPartCatalogItem, OuterPartCatalogItem,
} from "src/static/outer-car-parts-catalog"; } from "src/static/outer-car-parts-catalog";
import {
carPartDamageLabelForFactorRecord,
catalogItemToSelectedPart,
catalogLikeKeyFromPart,
coerceDamagedPartsMediaToArray,
migrateExpertReplyCarPartDamageToUnified,
normalizeDamageSelectedParts,
partIdentityKey,
resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts";
@Injectable() @Injectable()
export class ClaimRequestManagementService { export class ClaimRequestManagementService {
@@ -716,17 +724,32 @@ export class ClaimRequestManagementService {
throw new BadRequestException("Car part damage is already set."); throw new BadRequestException("Car part damage is already set.");
} }
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
const selectedParts = this.carDamagePartDtoToOuterPartSlugs(dto.carPartDamage); const selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
if (!selectedParts.length) { dto.carPartDamage,
);
if (!selectedSlugStrings.length) {
throw new BadRequestException( throw new BadRequestException(
"No outer damaged parts could be derived from carPartDamage. Check part selections.", "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( const updated = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId, claimRequestId,
{ {
"damage.selectedParts": selectedParts, "damage.selectedParts": selectedPartObjs,
"media.damagedParts": damagedPartsStubs,
status: ClaimCaseStatus.SELECTING_OTHER_PARTS, status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
@@ -744,7 +767,7 @@ export class ClaimRequestManagementService {
timestamp: new Date(), timestamp: new Date(),
metadata: { metadata: {
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
selectedParts, selectedParts: selectedPartObjs,
description: "Field expert submitted outer damage parts", description: "Field expert submitted outer damage parts",
}, },
}, },
@@ -1922,7 +1945,10 @@ export class ClaimRequestManagementService {
* - convert factorLink ObjectId -> public URL * - convert factorLink ObjectId -> public URL
* - enrich daghi.branchId with branchName when available * - enrich daghi.branchId with branchName when available
*/ */
private async mapEvaluationForClient(evaluation: any): Promise<any | undefined> { private async mapEvaluationForClient(
evaluation: any,
carType?: ClaimVehicleTypeV2,
): Promise<any | undefined> {
if (!evaluation) return undefined; if (!evaluation) return undefined;
const mapReply = async (reply: any) => { const mapReply = async (reply: any) => {
@@ -1988,6 +2014,13 @@ export class ClaimRequestManagementService {
}; };
} }
if (nextPart.carPartDamage != null) {
nextPart.carPartDamage = migrateExpertReplyCarPartDamageToUnified(
nextPart.carPartDamage,
carType,
);
}
return nextPart; return nextPart;
}); });
@@ -2032,7 +2065,9 @@ export class ClaimRequestManagementService {
const parts = finalReply?.parts || []; const parts = finalReply?.parts || [];
const missingFactors = parts const missingFactors = parts
.filter((p) => p.factorNeeded === true && !p.factorLink) .filter((p) => p.factorNeeded === true && !p.factorLink)
.map((p) => p.carPartDamage || p.partId); .map((p) =>
carPartDamageLabelForFactorRecord(p.carPartDamage) || p.partId,
);
if (missingFactors.length > 0) { if (missingFactors.length > 0) {
throw new HttpException( throw new HttpException(
@@ -2259,7 +2294,7 @@ export class ClaimRequestManagementService {
const factorRecord = await this.claimFactorsImageDbService.create({ const factorRecord = await this.claimFactorsImageDbService.create({
claimId, claimId,
partId, partId,
partName: part.carPartDamage, partName: carPartDamageLabelForFactorRecord(part.carPartDamage),
path: file.path, path: file.path,
fileName: file.filename, fileName: file.filename,
uploadedAt: new Date(), uploadedAt: new Date(),
@@ -2396,7 +2431,9 @@ export class ClaimRequestManagementService {
); );
} }
const partNameForRecord = part.carPartDamage ?? part.partId; const partNameForRecord =
carPartDamageLabelForFactorRecord(part.carPartDamage) ||
String(part.partId ?? "");
const factorRecord = await this.claimFactorsImageDbService.create({ const factorRecord = await this.claimFactorsImageDbService.create({
claimId: new Types.ObjectId(claimRequestId), claimId: new Types.ObjectId(claimRequestId),
@@ -3980,41 +4017,46 @@ export class ClaimRequestManagementService {
); );
} }
selectedParts = selectedItems.map((p) => p.key); const selectedPartDocs = selectedItems.map((p) =>
const selectedPartIds = selectedItems.map((p) => p.id); catalogItemToSelectedPart(p, catalog),
const selectedOuterParts = selectedItems.map((p) => ({ );
id: p.id, const damagedPartsInitial = selectedPartDocs.map((p) => ({
key: p.key, id: p.id ?? undefined,
name: p.name,
side: p.side, 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 // 6. Update claim case with selected parts and move to next step
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId, claimRequestId,
{ {
'damage.selectedParts': selectedParts, "damage.selectedParts": selectedPartDocs,
'damage.selectedOuterParts': selectedOuterParts, "media.damagedParts": damagedPartsInitial,
'vehicle.carType': selectedCarType, "vehicle.carType": selectedCarType,
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS, status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS, "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES, "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
$unset: { "damage.selectedOuterParts": "" },
$push: { $push: {
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OUTER_PARTS, "workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS,
history: { history: {
type: 'STEP_COMPLETED', type: "STEP_COMPLETED",
actor: { actor: {
actorId: new Types.ObjectId(currentUserId), actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User', actorName: claimCase.owner?.fullName || "User",
actorType: 'user', actorType: "user",
}, },
timestamp: new Date(), timestamp: new Date(),
metadata: { metadata: {
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
selectedParts: selectedParts, selectedParts: selectedPartDocs,
selectedPartIds, selectedPartIds,
carType: selectedCarType, carType: selectedCarType,
partsCount: selectedParts?.length, partsCount: selectedPartDocs?.length,
description: `User selected ${selectedParts?.length} damaged outer parts`, description: `User selected ${selectedPartDocs?.length} damaged outer parts`,
}, },
}, },
}, },
@@ -4022,22 +4064,23 @@ export class ClaimRequestManagementService {
); );
if (!updatedClaim) { if (!updatedClaim) {
throw new InternalServerErrorException('Failed to update claim case'); throw new InternalServerErrorException("Failed to update claim case");
} }
this.logger.log( this.logger.log(
`Outer parts selected for claim ${claimRequestId}: ${selectedParts?.length} parts`, `Outer parts selected for claim ${claimRequestId}: ${selectedPartDocs?.length} parts`,
); );
// 7. Return response // 7. Return response
return { return {
claimRequestId: updatedClaim._id.toString(), claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId, publicId: updatedClaim.publicId,
selectedParts: selectedParts, selectedParts: selectedPartDocs,
selectedPartIds, selectedPartIds,
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS, currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, 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) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -4290,19 +4333,6 @@ export class ClaimRequestManagementService {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item); 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_*) // Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
const blameRequest = claimCase.blameRequestId const blameRequest = claimCase.blameRequestId
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString()) ? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
@@ -4356,46 +4386,36 @@ export class ClaimRequestManagementService {
), ),
})); }));
// Damaged parts from selected parts const carType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedParts = claimCase.damage?.selectedParts || []; const selectedNorm = normalizeDamageSelectedParts(
const selectedOuterParts = Array.isArray(claimCase.damage?.selectedOuterParts) claimCase.damage?.selectedParts,
? claimCase.damage.selectedOuterParts carType,
: []; (claimCase.damage as any)?.selectedOuterParts,
const selectedOuterPartByKey = new Map<
string,
{ id?: number; side?: string }
>(
selectedOuterParts.map((p) => [
p.key,
{
id: p.id,
side: p.side,
},
]),
); );
const damagedParts = selectedParts.map(partKey => { const damagedParts = selectedNorm.map((sp) => {
const label = getPartLabel(partKey); const catalogMeta = sp.catalogKey
const selectedMeta = selectedOuterPartByKey.get(partKey); ? catalogByKey.get(sp.catalogKey)
const catalogMeta = catalogByKey.get(partKey); : sp.name
const side = selectedMeta?.side ?? catalogMeta?.side; ? catalogByKey.get(catalogLikeKeyFromPart(sp))
const sideFaMap: Record<string, string> = { : undefined;
left: "چپ", const labelEnName = sp.name
right: "راست", .replace(/([a-z])([A-Z])/g, "$1 $2")
front: "جلو", .replace(/_/g, " ")
back: "عقب", .trim()
top: "بالا", .replace(/\b\w/g, (c) => c.toUpperCase());
}; const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
const sideFa = side ? sideFaMap[side] : undefined;
return { return {
id: selectedMeta?.id ?? catalogMeta?.id, id: sp.id ?? catalogMeta?.id,
key: partKey, key: sp.name,
side, name: sp.name,
label_fa: sideFa ? `${label.fa} (${sideFa})` : label.fa, side: sp.side,
label_en: label.en, label_fa: sp.label_fa,
label_en: labelEnName,
captured: hasDamagedPartCapture( captured: hasDamagedPartCapture(
claimCase.media?.damagedParts as any, claimCase.media?.damagedParts as any,
partKey, ck,
selectedNorm,
), ),
}; };
}); });
@@ -4832,13 +4852,28 @@ 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) { if (treatAsAngle && angleCanon) {
updateData[`media.carAngles.${angleCanon}`] = captureData; updateData[`media.carAngles.${angleCanon}`] = captureData;
if (!Array.isArray(claimCase.media?.damagedParts)) {
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset( const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
angleCanon, angleCanon,
body.captureKey, body.captureKey,
selectedBefore, claimCase.damage?.selectedParts,
carType,
selectedOuterLegacy,
); );
if (unsetFieldKeys.length) { if (unsetFieldKeys.length) {
updateData.$unset = {}; updateData.$unset = {};
@@ -4846,34 +4881,61 @@ export class ClaimRequestManagementService {
updateData.$unset[`media.damagedParts.${k}`] = ""; updateData.$unset[`media.damagedParts.${k}`] = "";
} }
} }
}
} else { } else {
const canonicalPartKey = resolveCanonicalDamagedPartStorageKey( let idx = resolvePartCaptureIndex(
body.captureKey, body.captureKey,
selectedBefore, nextSelected,
nextMedia,
); );
updateData[`media.damagedParts.${canonicalPartKey}`] = captureData; if (isResendCapture && body.captureType === "part" && idx < 0) {
const raw = String(body.captureKey ?? "").trim(); const added = normalizeDamageSelectedParts(
const titleLegacy = [body.captureKey],
legacyDamagedPartTitleEnFromPartKey(canonicalPartKey); carType,
const unsetPartKeys = new Set<string>(); undefined,
if (raw && raw !== canonicalPartKey) unsetPartKeys.add(raw); );
if (titleLegacy && titleLegacy !== canonicalPartKey) { const row =
unsetPartKeys.add(titleLegacy); 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) { if (idx < 0) {
updateData.$unset = updateData.$unset || {}; throw new BadRequestException(
for (const k of unsetPartKeys) { `Unknown damaged-part captureKey "${body.captureKey}". Use catalog id, 0-based index, or catalog key.`,
updateData.$unset[`media.damagedParts.${k}`] = ""; );
} }
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 ( if (
isResendCapture && isResendCapture &&
body.captureType === "part" && nextSelected.length !== selectedBeforeNorm.length
!selectedBefore.includes(body.captureKey)
) { ) {
updateData.$addToSet = { "damage.selectedParts": body.captureKey }; updateData["damage.selectedParts"] = nextSelected;
}
} }
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
@@ -4908,10 +4970,14 @@ export class ClaimRequestManagementService {
data && (data instanceof Map ? data.get(key) : data[key]); data && (data instanceof Map ? data.get(key) : data[key]);
// Use same logic as getCaptureRequirementsV2 to determine completion // 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 carAnglesData = updatedClaim?.media?.carAngles as any;
const damagedPartsData = updatedClaim?.media?.damagedParts 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) => const anglesCaptured = anglesKeys.filter((k) =>
hasClaimCarAngleCapture( hasClaimCarAngleCapture(
@@ -4920,12 +4986,17 @@ export class ClaimRequestManagementService {
k as ClaimCarAngleKey, k as ClaimCarAngleKey,
), ),
).length; ).length;
const partsCaptured = selectedParts.filter((p) => const partsCaptured = selectedNormAfter.filter((sp) => {
hasDamagedPartCapture(damagedPartsData, p), const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
).length; return hasDamagedPartCapture(
damagedPartsData,
ck,
selectedNormAfter,
);
}).length;
const allCapturesComplete = const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedParts.length; anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length;
if (allCapturesComplete) { if (allCapturesComplete) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
@@ -4952,7 +5023,8 @@ export class ClaimRequestManagementService {
}); });
} }
const remaining = (4 - anglesCaptured) + (selectedParts.length - partsCaptured); const remaining =
(4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured);
const message = allCapturesComplete const message = allCapturesComplete
? 'All captures complete. Please proceed to upload required documents.' ? '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} captures remaining.`;
@@ -5190,11 +5262,29 @@ export class ClaimRequestManagementService {
side: p.side, 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) { for (const np of newParts) {
const key = np.partName?.trim(); const name = np.partName?.trim();
if (key && !mergedSelected.includes(key)) { if (!name) continue;
mergedSelected.push(key); 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);
} }
} }
@@ -5212,7 +5302,7 @@ export class ClaimRequestManagementService {
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
'workflow.nextStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION, 'workflow.nextStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
'evaluation.objection': objectionPayload, 'evaluation.objection': objectionPayload,
'damage.selectedParts': mergedSelected, 'damage.selectedParts': mergedNorm,
}, },
$unset: { $unset: {
'evaluation.ownerPricedPartsApproval': "", 'evaluation.ownerPricedPartsApproval': "",
@@ -5692,47 +5782,56 @@ export class ClaimRequestManagementService {
}; };
} }
const getPartLabelFa = (key: string): string => { const carTypeDetails = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const labels: Record<string, string> = { const selectedNormDetails = normalizeDamageSelectedParts(
hood: 'کاپوت', claim.damage?.selectedParts,
trunk: 'صندوق عقب', carTypeDetails,
roof: 'سقف', (claim.damage as any)?.selectedOuterParts,
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 damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<
string,
{ label_fa: string; captured: boolean; url?: string }
> = {};
const resendPartKeys = normalizeResendPartKeys( const resendPartKeys = normalizeResendPartKeys(
claim.evaluation?.damageExpertResend?.resendCarParts, claim.evaluation?.damageExpertResend?.resendCarParts,
); );
const partKeysForDetails = new Set<string>([ const displayParts = [...selectedNormDetails];
...((claim.damage?.selectedParts || []) as string[]), const seenDetailKeys = new Set(
...resendPartKeys, selectedNormDetails.map((p) => partIdentityKey(p)),
]); );
for (const p of partKeysForDetails) { for (const rk of resendPartKeys) {
const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as const extra =
| { url?: string; path?: string } normalizeDamageSelectedParts([rk], carTypeDetails, undefined)[0] ||
| undefined; {
damagedParts[p] = { id: null,
label_fa: getPartLabelFa(p), 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, captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), 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 er = claim.evaluation?.damageExpertResend;
const expertResend = const expertResend =
@@ -5773,7 +5872,10 @@ export class ClaimRequestManagementService {
} }
: undefined; : undefined;
const mappedEvaluation = await this.mapEvaluationForClient(claim.evaluation); const mappedEvaluation = await this.mapEvaluationForClient(
claim.evaluation,
claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
);
return { return {
claimRequestId: claim._id.toString(), claimRequestId: claim._id.toString(),
@@ -5787,7 +5889,7 @@ export class ClaimRequestManagementService {
blameRequestNo: claim.blameRequestNo, blameRequestNo: claim.blameRequestNo,
...(ownerData ? { owner: ownerData } : {}), ...(ownerData ? { owner: ownerData } : {}),
vehicle: claim.vehicle, vehicle: claim.vehicle,
selectedParts: claim.damage?.selectedParts, selectedParts: selectedNormDetails,
otherParts: claim.damage?.otherParts, otherParts: claim.damage?.otherParts,
money: isExpertViewer ? moneyForExpert : moneyForUser, money: isExpertViewer ? moneyForExpert : moneyForUser,
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined, requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,

View File

@@ -1,6 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; 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 * V2 DTO for capturing car angle or damaged part
@@ -18,7 +17,8 @@ export class CapturePartV2Dto {
captureType: 'angle' | 'part'; captureType: 'angle' | 'part';
@ApiProperty({ @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', example: 'front',
}) })
@IsNotEmpty({ message: 'Capture key is required' }) @IsNotEmpty({ message: 'Capture key is required' })

View File

@@ -72,10 +72,17 @@ export class CarAngleItem {
*/ */
export class DamagedPartItem { export class DamagedPartItem {
@ApiProperty({ @ApiProperty({
description: 'Part key', description:
example: 'hood', '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({ @ApiProperty({
description: 'Display label in Farsi', description: 'Display label in Farsi',
@@ -83,6 +90,12 @@ export class DamagedPartItem {
}) })
label_fa: string; label_fa: string;
@ApiPropertyOptional({
description: 'Deprecated: same as `name` (kept for older clients)',
example: 'backfender',
})
key?: string;
@ApiProperty({ @ApiProperty({
description: 'Display label in English', description: 'Display label in English',
example: 'Hood', example: 'Hood',

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DamageSelectedPartV2BodyDto } from './damage-selected-part-v2.dto';
/** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */ /** Suggested HTTP call for the owner UI (`pathTemplate`: replace placeholders). */
export class ClaimDetailsOwnerNextActionV2Dto { export class ClaimDetailsOwnerNextActionV2Dto {
@@ -119,8 +120,11 @@ export class ClaimDetailsV2ResponseDto {
plate?: any; plate?: any;
}; };
@ApiPropertyOptional({ description: 'Selected outer damaged parts' }) @ApiPropertyOptional({
selectedParts?: string[]; description: 'Selected outer damaged parts (ordered objects with id, name, side, label_fa)',
type: [DamageSelectedPartV2BodyDto],
})
selectedParts?: DamageSelectedPartV2BodyDto[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' }) @ApiPropertyOptional({ description: 'Selected other damaged parts' })
otherParts?: string[]; otherParts?: string[];
@@ -137,8 +141,36 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Car angles captured' }) @ApiPropertyOptional({ description: 'Car angles captured' })
carAngles?: Record<string, { captured: boolean; url?: string }>; carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' }) @ApiPropertyOptional({
damagedParts?: Record<string, { label_fa: string; captured: boolean; url?: string }>; 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({ @ApiPropertyOptional({
description: description:

View File

@@ -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;
}

View File

@@ -4,6 +4,7 @@ import {
ClaimVehicleTypeV2, ClaimVehicleTypeV2,
OuterPartSideV2, OuterPartSideV2,
} from "src/static/outer-car-parts-catalog"; } 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 * Enum for valid outer car parts that can be damaged
@@ -97,10 +98,11 @@ export class SelectOuterPartsV2ResponseDto {
publicId: string; publicId: string;
@ApiProperty({ @ApiProperty({
description: 'Array of selected damaged parts', description:
example: ['hood', 'front_right_door', 'rear_bumper'], "Ordered selected parts: id, side-agnostic name, side, label_fa (and optional catalogKey)",
type: [DamageSelectedPartV2BodyDto],
}) })
selectedParts: string[]; selectedParts: DamageSelectedPartV2BodyDto[];
@ApiProperty({ @ApiProperty({
description: 'Selected part IDs', description: 'Selected part IDs',

View File

@@ -1,32 +1,15 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema } from "mongoose";
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema"; 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 }) @Schema({ _id: false })
export class ClaimDamageSelection { export class ClaimDamageSelection {
/** /**
* V2: Array of selected damaged outer car part names * V2: Selected outer damaged parts (ordered). Prefer objects
* Examples: ['hood', 'front_right_door', 'rear_bumper'] * `{ id, name, side, label_fa, catalogKey? }`; legacy string keys are still accepted until migrated.
*/ */
@Prop({ type: [String], default: [] }) @Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
selectedParts?: string[]; selectedParts?: unknown[];
/** Structured selected outer parts with id + side for better downstream handling. */
@Prop({ type: [SelectedOuterPartV2Schema], default: [] })
selectedOuterParts?: SelectedOuterPartV2[];
/** /**
* V2: Array of selected other (non-body) damaged parts * V2: Array of selected other (non-body) damaged parts
@@ -46,4 +29,3 @@ export class ClaimDamageSelection {
} }
export const ClaimDamageSelectionSchema = export const ClaimDamageSelectionSchema =
SchemaFactory.createForClass(ClaimDamageSelection); SchemaFactory.createForClass(ClaimDamageSelection);

View File

@@ -13,11 +13,12 @@ export class ClaimPartPricing {
partId: string; 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. * Must be Mixed — objects cannot cast to String.
*/ */
@Prop({ type: MongooseSchema.Types.Mixed }) @Prop({ type: MongooseSchema.Types.Mixed })
carPartDamage?: string | { part?: string; side?: string }; carPartDamage?: unknown;
@Prop({ type: String }) @Prop({ type: String })
typeOfDamage?: string; typeOfDamage?: string;

View File

@@ -1,5 +1,5 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; 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 { AiImagesModel } from "./ai-image.schema";
import { CarGreenCardModel } from "./car-green-card.schema"; import { CarGreenCardModel } from "./car-green-card.schema";
import { ImageRequiredModel } from "./image-required.schema"; import { ImageRequiredModel } from "./image-required.schema";
@@ -20,6 +20,39 @@ export class CapturedImage {
} }
export const CapturedImageSchema = SchemaFactory.createForClass(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 }) @Schema({ _id: false })
export class ClaimMedia { export class ClaimMedia {
@Prop({ type: CarGreenCardModel }) @Prop({ type: CarGreenCardModel })
@@ -46,15 +79,10 @@ export class ClaimMedia {
carAngles?: Map<string, CapturedImage>; carAngles?: Map<string, CapturedImage>;
/** /**
* V2: Damaged parts captures * V2: Damaged parts captures — prefer an array (index matches `damage.selectedParts`).
* Map of part key to captured image * Legacy documents may store a plain object map keyed by part slug until migrated on write.
*/ */
@Prop({ @Prop({ type: MongooseSchema.Types.Mixed })
type: Map, damagedParts?: DamagedPartMediaV2Row[] | Record<string, unknown>;
of: CapturedImageSchema,
default: () => ({}),
})
damagedParts?: Map<string, CapturedImage>;
} }
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia); export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);

View File

@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum'; 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 { export class ClaimDetailV2ResponseDto {
@ApiProperty() @ApiProperty()
@@ -64,8 +65,11 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' }) @ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
blameRequestNo?: string; blameRequestNo?: string;
@ApiPropertyOptional({ description: 'Selected outer damaged parts' }) @ApiPropertyOptional({
selectedParts?: string[]; description: 'Selected outer damaged parts (id, name, side, label_fa)',
type: [DamageSelectedPartV2BodyDto],
})
selectedParts?: DamageSelectedPartV2BodyDto[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' }) @ApiPropertyOptional({ description: 'Selected other damaged parts' })
otherParts?: string[]; otherParts?: string[];
@@ -81,8 +85,32 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ description: 'Car angles captured' }) @ApiPropertyOptional({ description: 'Car angles captured' })
carAngles?: Record<string, { captured: boolean; url?: string }>; carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' }) @ApiPropertyOptional({
damagedParts?: Record<string, { captured: boolean; url?: string }>; 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({ @ApiPropertyOptional({
description: description:

View File

@@ -7,20 +7,57 @@ import {
IsOptional, IsOptional,
ValidateNested, ValidateNested,
IsEnum, IsEnum,
IsInt,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; 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 { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum'; import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
export class CarPartDamageV2Dto { /**
@ApiProperty({ example: 'left' }) * Damage line part reference — same unified shape as `damage.selectedParts` / catalog rows.
@IsString() * Send either `{ name, side, label_fa?, id?, catalogKey? }` or legacy `{ part, side }`
side: string; * (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() @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). */ /** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
@@ -54,10 +91,10 @@ export class PartPricingV2Dto {
@IsNotEmpty() @IsNotEmpty()
partId: string; partId: string;
@ApiProperty({ type: CarPartDamageV2Dto }) @ApiProperty({ type: ExpertReplyCarPartDamageV2Dto })
@ValidateNested() @ValidateNested()
@Type(() => CarPartDamageV2Dto) @Type(() => ExpertReplyCarPartDamageV2Dto)
carPartDamage: CarPartDamageV2Dto; carPartDamage: ExpertReplyCarPartDamageV2Dto;
@ApiProperty({ example: 'Minor' }) @ApiProperty({ example: 'Minor' })
@IsString() @IsString()

View File

@@ -1,25 +1,20 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { import {
ArrayMinSize, ArrayMinSize,
ArrayUnique,
IsArray, IsArray,
IsEnum, ValidateNested,
IsNotEmpty,
} from "class-validator"; } 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 { export class UpdateClaimDamagedPartsV2Dto {
@ApiProperty({ @ApiProperty({
description: "Updated list of selected damaged outer parts", type: [DamageSelectedPartV2BodyDto],
enum: OuterCarPart, description: "Full replacement list of selected damaged outer parts (id, name, side, label_fa)",
isArray: true,
example: ["hood", "front_bumper", "front_left_fender"],
}) })
@IsNotEmpty()
@IsArray() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@ArrayUnique() @ValidateNested({ each: true })
@IsEnum(OuterCarPart, { each: true }) @Type(() => DamageSelectedPartV2BodyDto)
selectedParts: OuterCarPart[]; selectedParts: DamageSelectedPartV2BodyDto[];
} }

View File

@@ -71,8 +71,16 @@ import {
import { import {
getClaimCarAngleCaptureBlob, getClaimCarAngleCaptureBlob,
getDamagedPartCaptureBlob, getDamagedPartCaptureBlob,
hasDamagedPartCapture,
type ClaimCarAngleKey, type ClaimCarAngleKey,
} from "src/helpers/claim-car-angle-media"; } 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 { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
@@ -2532,10 +2540,27 @@ export class ExpertClaimService {
}); });
} }
const processedParts = const carTypeSubmit = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const daghiNormalized =
reply.parts?.length > 0 reply.parts?.length > 0
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts) ? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
: []; : [];
const processedParts = daghiNormalized.map((p) => {
let carPartDamage: Record<string, unknown>;
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( const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
processedParts, processedParts,
@@ -3317,40 +3342,35 @@ export class ExpertClaimService {
}; };
} }
// Build damaged parts map // Build damaged parts list (aligned with normalized selected parts)
const getPartLabelFa = (key: string): string => { const carTypeExpert = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const labels: Record<string, string> = { const selectedNormExpert = normalizeDamageSelectedParts(
hood: "کاپوت", claim.damage?.selectedParts,
trunk: "صندوق عقب", carTypeExpert,
roof: "سقف", (claim.damage as any)?.selectedOuterParts,
front_right_door: "درب جلو راست", );
front_left_door: "درب جلو چپ", const damagedPartsDataExpert = claim.media?.damagedParts as any;
rear_right_door: "درب عقب راست", const damagedParts = selectedNormExpert.map((sp, index) => {
rear_left_door: "درب عقب چپ", const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
front_bumper: "سپر جلو", const cap = getDamagedPartCaptureBlob(
rear_bumper: "سپر عقب", damagedPartsDataExpert,
front_right_fender: "گلگیر جلو راست", ck,
front_left_fender: "گلگیر جلو چپ", selectedNormExpert,
rear_right_fender: "گلگیر عقب راست", ) as { url?: string; path?: string } | undefined;
rear_left_fender: "گلگیر عقب چپ", return {
}; index,
return labels[key] || key; id: sp.id,
}; name: sp.name,
const damagedPartsData = claim.media?.damagedParts as any; side: sp.side,
const damagedParts: Record< label_fa: sp.label_fa,
string, captured: hasDamagedPartCapture(
{ label_fa: string; captured: boolean; url?: string } damagedPartsDataExpert,
> = {}; ck,
for (const p of claim.damage?.selectedParts || []) { selectedNormExpert,
const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as ),
| { url?: string; path?: string }
| undefined;
damagedParts[p] = {
label_fa: getPartLabelFa(p),
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined), url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
}; };
} });
// --- Combine both branches of the conflict, preserving necessary logic from both --- // --- Combine both branches of the conflict, preserving necessary logic from both ---
@@ -3480,7 +3500,7 @@ export class ExpertClaimService {
blameRequestId: claim.blameRequestId?.toString(), blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo, blameRequestNo: claim.blameRequestNo,
money: moneyPayload, money: moneyPayload,
selectedParts: claim.damage?.selectedParts, selectedParts: selectedNormExpert,
otherParts: claim.damage?.otherParts, otherParts: claim.damage?.otherParts,
requiredDocuments: requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0 Object.keys(requiredDocumentsStatus).length > 0
@@ -3535,35 +3555,70 @@ export class ExpertClaimService {
const damagedPartsEditSnapshot = await this.snapshotDamageExpert(actor.sub); 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<string, unknown>)
: {};
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) const previous = Array.isArray((claim as any).damage?.selectedParts)
? [...(claim as any).damage.selectedParts] ? [...(claim as any).damage.selectedParts]
: []; : [];
const selectedParts = body.selectedParts as string[];
const $set: Record<string, unknown> = { const $set: Record<string, unknown> = {
"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<string, unknown>);
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<string, unknown>),
};
for (const key of Object.keys(o)) {
if (!selectedSet.has(key)) delete o[key];
}
$set["media.damagedParts"] = o;
}
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set, $set,
$push: { $push: {
@@ -3577,7 +3632,7 @@ export class ExpertClaimService {
timestamp: new Date(), timestamp: new Date(),
metadata: { metadata: {
previousSelectedParts: previous, previousSelectedParts: previous,
selectedParts, selectedParts: nextNorm,
...(damagedPartsEditSnapshot && { ...(damagedPartsEditSnapshot && {
expertProfileSnapshot: damagedPartsEditSnapshot, expertProfileSnapshot: damagedPartsEditSnapshot,
}), }),
@@ -3588,7 +3643,7 @@ export class ExpertClaimService {
return { return {
claimRequestId: claim._id.toString(), claimRequestId: claim._id.toString(),
selectedParts, selectedParts: nextNorm,
previousSelectedParts: previous, previousSelectedParts: previous,
message: "Damaged parts updated successfully.", message: "Damaged parts updated successfully.",
}; };

View File

@@ -5,6 +5,13 @@
* into {@code media.damagedParts} and broke step-completion checks. * 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 const CLAIM_CAR_ANGLE_KEYS = ["front", "back", "left", "right"] as const;
export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number]; export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number];
@@ -54,9 +61,17 @@ export function hasClaimCarAngleCapture(
export function legacyAngleDamagedPartFieldKeysToUnset( export function legacyAngleDamagedPartFieldKeysToUnset(
canonical: ClaimCarAngleKey, canonical: ClaimCarAngleKey,
rawCaptureKey: string, rawCaptureKey: string,
selectedParts: string[], selectedParts: unknown,
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
selectedOuterPartsLegacy?: unknown,
): string[] { ): string[] {
const selected = new Set((selectedParts ?? []).map((p) => String(p))); const selected = new Set(
damageSelectedPartsToLookupStrings(
selectedParts,
carType,
selectedOuterPartsLegacy,
),
);
const variants = new Set( const variants = new Set(
[ [
rawCaptureKey, rawCaptureKey,
@@ -89,9 +104,23 @@ function normalizeDamagedPartKeyLoose(s: string): string {
export function getDamagedPartCaptureBlob( export function getDamagedPartCaptureBlob(
damagedPartsData: unknown, damagedPartsData: unknown,
partKey: string, partKey: string,
selectedNormalized?: DamageSelectedPartV2[],
): unknown { ): unknown {
const pk = String(partKey ?? "").trim(); const pk = String(partKey ?? "").trim();
if (!pk || damagedPartsData == null) return undefined; if (!pk || damagedPartsData == null) return undefined;
if (Array.isArray(damagedPartsData)) {
const norm = selectedNormalized ?? [];
const idx = resolvePartCaptureIndex(
pk,
norm,
damagedPartsData as Record<string, unknown>[],
);
if (idx >= 0) {
const row = (damagedPartsData as unknown[])[idx];
if (mediaRowHasCapture(row)) return row;
}
return undefined;
}
let b = getCaptureBlob(damagedPartsData, pk); let b = getCaptureBlob(damagedPartsData, pk);
if (b) return b; if (b) return b;
const titleEn = legacyDamagedPartTitleEnFromPartKey(pk); const titleEn = legacyDamagedPartTitleEnFromPartKey(pk);
@@ -120,8 +149,12 @@ export function getDamagedPartCaptureBlob(
export function hasDamagedPartCapture( export function hasDamagedPartCapture(
damagedPartsData: unknown, damagedPartsData: unknown,
partKey: string, partKey: string,
selectedNormalized?: DamageSelectedPartV2[],
): boolean { ): boolean {
return getDamagedPartCaptureBlob(damagedPartsData, partKey) != null; return (
getDamagedPartCaptureBlob(damagedPartsData, partKey, selectedNormalized) !=
null
);
} }
/** /**
@@ -129,11 +162,17 @@ export function hasDamagedPartCapture(
*/ */
export function resolveCanonicalDamagedPartStorageKey( export function resolveCanonicalDamagedPartStorageKey(
captureKeyRaw: string, captureKeyRaw: string,
selectedParts: string[], selectedParts: unknown,
carType?: import("src/static/outer-car-parts-catalog").ClaimVehicleTypeV2,
selectedOuterPartsLegacy?: unknown,
): string { ): string {
const raw = String(captureKeyRaw ?? "").trim(); const raw = String(captureKeyRaw ?? "").trim();
if (!raw) return raw; 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; if (parts.includes(raw)) return raw;
const n = normalizeDamagedPartKeyLoose(raw); const n = normalizeDamagedPartKeyLoose(raw);
const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n); const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n);

View File

@@ -1,5 +1,8 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; 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 { 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"; import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
export function resendRequestHasPayload(r: { export function resendRequestHasPayload(r: {
@@ -59,8 +62,12 @@ export function isRequiredDocumentUploaded(claim: any, documentKey: string): boo
export function hasDamagedPartCapture(claim: any, partKey: string): boolean { export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
const data = claim?.media?.damagedParts; const data = claim?.media?.damagedParts;
if (!data) return false; if (!data) return false;
const cap = data instanceof Map ? data.get(partKey) : data[partKey]; const norm = normalizeDamageSelectedParts(
return !!cap; claim?.damage?.selectedParts,
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
claim?.damage?.selectedOuterParts,
);
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
} }
/** /**

View File

@@ -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<string>(Object.values(OuterPartSideV2));
/** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */
const SIDE_LABEL_FA: Record<string, string> = {
[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<number, OuterPartCatalogItem>();
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<DamageSelectedPartV2, "side" | "name">,
): 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<string, string> = {
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<string, { id?: number; side?: string }>();
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<string, unknown>;
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<string, unknown> {
const r: Record<string, unknown> = {
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<string, unknown> {
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<string, unknown>;
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<string, unknown> = {
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<string, unknown> {
try {
return normalizeCarPartDamageForExpertReply(raw, carType);
} catch {
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
return { ...(raw as Record<string, unknown>) };
}
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<string, unknown>;
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<string, unknown> | null {
if (data == null) return null;
if (data instanceof Map) {
return Object.fromEntries((data as Map<string, unknown>).entries());
}
if (typeof data === "object" && !Array.isArray(data)) {
return data as Record<string, unknown>;
}
return null;
}
function getCaptureBlobLoose(
legacyMap: Record<string, unknown>,
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<string, unknown>,
selected: DamageSelectedPartV2[],
): Record<string, unknown>[] {
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<string, unknown>;
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<string, unknown>[] {
if (Array.isArray(damagedPartsData)) {
const arr = damagedPartsData.map((x) =>
typeof x === "object" && x ? { ...(x as object) } : {},
) as Record<string, unknown>[];
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<string, unknown>[],
): 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<string>();
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 [];
}