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)); const SIDE_FA_TO_EN: Record = { چپ: OuterPartSideV2.LEFT, راست: OuterPartSideV2.RIGHT, جلو: OuterPartSideV2.FRONT, عقب: OuterPartSideV2.BACK, بالا: OuterPartSideV2.TOP, }; function containsPersianText(s: string): boolean { return /[\u0600-\u06FF]/.test(s); } /** e.g. `گلگیر عقب (چپ)` → title + optional side in Farsi. */ function parsePersianPartLabel(label: string): { titleFa: string; sideFa?: string; } { const t = String(label ?? "").trim(); const m = t.match(/^(.+?)\s*\(([^)]+)\)\s*$/); if (m) return { titleFa: m[1].trim(), sideFa: m[2].trim() }; return { titleFa: t }; } /** Match disambiguated Farsi labels back to a catalog row for this car type. */ export function findCatalogItemByPersianLabel( label: string, carType: ClaimVehicleTypeV2, ): OuterPartCatalogItem | undefined { const catalog = OUTER_PARTS_BY_CAR_TYPE[carType]; if (!catalog?.length) return undefined; const { titleFa, sideFa } = parsePersianPartLabel(label); if (!titleFa) return undefined; const sideEn = sideFa ? SIDE_FA_TO_EN[sideFa] : undefined; const candidates = catalog.filter( (c) => String(c.titleFa).trim() === titleFa, ); if (!candidates.length) return undefined; if (sideEn) { return candidates.find((c) => c.side === sideEn) ?? candidates[0]; } return candidates.length === 1 ? candidates[0] : undefined; } /** Non-catalog lines (expert-added interior / free-text); never use catalog numeric ids. */ export function isInternalDamageSide(side: string): boolean { const s = String(side ?? "") .trim() .toLowerCase(); if (!s || s === "internal" || s === "other") return true; return !SIDE_SET.has(s); } /** Parse API / DB part id (number, `"201"`, or legacy `"id:201"`) → catalog id. */ export function parseCatalogPartIdInput( partId: number | string | null | undefined, ): number | null { if (partId == null || partId === "") return null; if (typeof partId === "number" && Number.isFinite(partId)) { return Math.trunc(partId); } const raw = String(partId).trim(); const legacy = raw.match(/^id:(\d+)$/i); if (legacy) return Number(legacy[1]); if (/^\d+$/.test(raw)) return Number(raw); return null; } export function sameCatalogPartId( a: number | string | null | undefined, b: number | string | null | undefined, ): boolean { const na = parseCatalogPartIdInput(a); const nb = parseCatalogPartIdInput(b); return na != null && nb != null && na === nb; } /** API part id: numeric catalog id only (`null` for internal / non-catalog lines). */ export function catalogPartIdFromSelectedPart( sp: DamageSelectedPartV2, ): number | null { if (isInternalDamageSide(sp.side)) return null; if (sp.id != null && Number.isFinite(sp.id)) return sp.id; return null; } /** Internal dedupe / merge key — not for client `partId`. */ export function partLookupKey(p: DamageSelectedPartV2): string { const catalogId = catalogPartIdFromSelectedPart(p); if (catalogId != null) return `catalog:${catalogId}`; if (isInternalDamageSide(p.side)) return `internal:${p.name}`; return `legacy:${p.side}|${p.name}`; } /** @deprecated Use {@link partLookupKey} for maps or {@link catalogPartIdFromSelectedPart} for APIs. */ export const partIdentityKey = partLookupKey; /** 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; const idOnly = toNum(o.id); const hasName = typeof o.name === "string" && o.name.trim(); const hasKey = typeof o.key === "string" && String(o.key).trim(); if (idOnly != null && !hasName && !hasKey) { let catItem: OuterPartCatalogItem | undefined; if (catalog) catItem = catalog.find((c) => c.id === idOnly); if (!catItem) catItem = CATALOG_ITEM_BY_ID.get(idOnly); if (catItem) { const list = catalog ?? outerCatalogListForItem(catItem); out.push(catalogItemToSelectedPart(catItem, list)); continue; } } 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.map((p) => sanitizeDamageSelectedPartV2(p, carType)); } /** * Fix legacy / mistaken rows (Persian in `side`, label in `name`, bad `catalogKey`). * Re-hydrate from catalog when `id` or valid `catalogKey` + `carType` are known. */ export function sanitizeDamageSelectedPartV2( row: DamageSelectedPartV2, carType?: ClaimVehicleTypeV2, ): DamageSelectedPartV2 { let { id, name, side, label_fa, catalogKey } = row; const sideTrim = String(side ?? "").trim(); const nameTrim = String(name ?? "").trim(); let labelTrim = String(label_fa ?? "").trim(); if (SIDE_FA_TO_EN[sideTrim]) { side = SIDE_FA_TO_EN[sideTrim]; } if ( containsPersianText(nameTrim) && !containsPersianText(labelTrim) && labelTrim && SIDE_SET.has(labelTrim.toLowerCase()) ) { const swappedSide = labelTrim.toLowerCase(); labelTrim = nameTrim; name = nameTrim; side = swappedSide; } else if (containsPersianText(nameTrim) && !labelTrim) { labelTrim = nameTrim; name = normPartSegment(nameTrim) || nameTrim; } if (catalogKey && !String(catalogKey).includes("_")) { if (SIDE_FA_TO_EN[String(catalogKey).trim()]) { catalogKey = undefined; } } const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined; if (isInternalDamageSide(side) && catalog?.length) { const persianLabel = labelTrim || nameTrim; if (persianLabel && containsPersianText(persianLabel)) { const recovered = findCatalogItemByPersianLabel(persianLabel, carType!); if (recovered) { return catalogItemToSelectedPart(recovered, catalog); } } if (id != null && Number.isFinite(id)) { const byId = catalog.find((c) => c.id === id); if (byId) return catalogItemToSelectedPart(byId, catalog); } } if (isInternalDamageSide(side)) { return { id: null, name: nameTrim || labelTrim || name, side: "internal", label_fa: labelTrim || nameTrim || name, }; } if (catalog?.length) { if (id != null && Number.isFinite(id)) { const byId = catalog.find((c) => c.id === id); if (byId) return catalogItemToSelectedPart(byId, catalog); } const ck = catalogKey?.trim(); if (ck && ck.includes("_")) { const byKey = catalog.find((c) => c.key === ck); if (byKey) return catalogItemToSelectedPart(byKey, catalog); } if (SIDE_SET.has(String(side).toLowerCase()) && nameTrim) { const byComposite = catalog.find( (c) => c.key === catalogLikeKeyFromPart({ side, name: nameTrim }), ); if (byComposite) return catalogItemToSelectedPart(byComposite, catalog); } const persianLabel = labelTrim || nameTrim; if (persianLabel && containsPersianText(persianLabel)) { const recovered = findCatalogItemByPersianLabel(persianLabel, carType!); if (recovered) return catalogItemToSelectedPart(recovered, catalog); } } return { id: id ?? null, name: nameTrim, side: String(side ?? "").trim(), label_fa: labelTrim || nameTrim, ...(catalogKey && String(catalogKey).includes("_") ? { catalogKey: String(catalogKey).trim() } : {}), }; } /** * Resolve a client `partId` (numeric catalog id) to a selected part row. */ export function resolveSelectedPartByPartId( partId: number | string, selected: DamageSelectedPartV2[], ): DamageSelectedPartV2 | undefined { if (!selected.length) return undefined; const catalogId = parseCatalogPartIdInput(partId); if (catalogId != null) { const hits = selected.filter((p) => p.id === catalogId); if (hits.length === 1) return hits[0]; if (hits.length > 1) return hits[0]; } const raw = String(partId ?? "").trim(); const indexColon = raw.match(/^index:(\d+)$/i); if (indexColon) { const i = Number(indexColon[1]); if (Number.isInteger(i) && i >= 0 && i < selected.length) { return selected[i]; } } return undefined; } /** Resolve a catalog row by numeric id when not on the claim yet. */ export function resolveCatalogPartByPartId( partId: number | string, carType?: ClaimVehicleTypeV2, ): DamageSelectedPartV2 | undefined { const catalogId = parseCatalogPartIdInput(partId); if (catalogId == null) return undefined; if (carType) { const list = OUTER_PARTS_BY_CAR_TYPE[carType]; const item = list?.find((c) => c.id === catalogId); if (item) return catalogItemToSelectedPart(item, list); } const global = CATALOG_ITEM_BY_ID.get(catalogId); if (global) { return catalogItemToSelectedPart(global, outerCatalogListForItem(global)); } return undefined; } /** Resolve expert reply / price-drop line by numeric catalog `partId`. */ export function resolvePartForExpertReply( partId: number | string, selected: DamageSelectedPartV2[], carType?: ClaimVehicleTypeV2, ): DamageSelectedPartV2 | undefined { const catalogId = parseCatalogPartIdInput(partId); if (catalogId == null) return undefined; const fromSelected = resolveSelectedPartByPartId(catalogId, selected); if (fromSelected) return fromSelected; return resolveCatalogPartByPartId(catalogId, carType); } /** Numeric catalog id from a persisted `carPartDamage` snapshot. */ export function catalogPartIdFromCarPartDamage( carPartDamage: unknown, ): number | null { if ( !carPartDamage || typeof carPartDamage !== "object" || Array.isArray(carPartDamage) ) { return null; } const o = carPartDamage as Record; return parseCatalogPartIdInput( typeof o.id === "number" || typeof o.id === "string" ? o.id : null, ); } /** @deprecated Use {@link catalogPartIdFromCarPartDamage}. */ export const partIdentityKeyFromCarPartDamage = catalogPartIdFromCarPartDamage; 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) { // Pass 1: exact matches on catalogKey and catalogLikeKey for (let i = 0; i < selected.length; i++) { const sp = selected[i]; if (sp.catalogKey === raw) return i; if (catalogLikeKeyFromPart(sp) === raw) return i; } // Pass 2: numeric id or array index 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; } // Pass 3: loose normalized match (handles spaces, underscores, case) const loose = raw.toLowerCase().replace(/[\s_-]/g, ""); for (let i = 0; i < selected.length; i++) { const sp = selected[i]; const candidates = [ sp.catalogKey, catalogLikeKeyFromPart(sp), sp.name, ].filter(Boolean) as string[]; for (const c of candidates) { if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i; } } // Pass 4: suffix match — handles car-type-prefixed catalog keys // e.g. "sedan_front_hood" should match sp.catalogKey="front_hood" // or normalizeResendPartKeys output "sedan_front_hood" vs stored "front_hood" for (let i = 0; i < selected.length; i++) { const sp = selected[i]; const candidates = [ sp.catalogKey, catalogLikeKeyFromPart(sp), sp.name, ].filter(Boolean) as string[]; for (const c of candidates) { const cLoose = c.toLowerCase().replace(/[\s_-]/g, ""); // raw ends with the candidate (prefix stripped) if (loose.endsWith(cLoose) || cLoose.endsWith(loose)) 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 []; }