import { ClaimVehicleTypeV2, OUTER_PARTS_BY_CAR_TYPE, OuterPartCatalogItem, OuterPartSideV2, } from "src/static/outer-car-parts-catalog"; /** Stored on `damage.selectedParts` and used when resolving captures / APIs. */ export type DamageSelectedPartV2 = { id: number | null; name: string; side: string; label_fa: string; /** Original catalog key when known (e.g. `left_backfender`). */ catalogKey?: string; }; const SIDE_SET = new Set(Object.values(OuterPartSideV2)); /** Farsi side suffix for disambiguated labels, e.g. `گلگیر عقب (چپ)`. */ const SIDE_LABEL_FA: Record = { [OuterPartSideV2.LEFT]: "چپ", [OuterPartSideV2.RIGHT]: "راست", [OuterPartSideV2.FRONT]: "جلو", [OuterPartSideV2.BACK]: "عقب", [OuterPartSideV2.TOP]: "بالا", }; function outerCatalogListForItem(item: OuterPartCatalogItem): OuterPartCatalogItem[] { for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { if (list.some((x) => x.key === item.key || x.id === item.id)) { return list; } } return [item]; } /** * When multiple catalog rows share the same `titleFa` (e.g. left/right rear fender), * append ` (side)` in Farsi so clients can display `label_fa` alone. */ export function disambiguateOuterPartLabelFa( baseTitleFa: string, side: string, sameTypeCatalog: OuterPartCatalogItem[], ): string { const t = String(baseTitleFa ?? "").trim(); const s = String(side ?? "").trim(); if (!t || !sameTypeCatalog?.length) return t; const norm = (x: string) => x.trim(); const siblings = sameTypeCatalog.filter((p) => norm(p.titleFa) === norm(t)); if (siblings.length <= 1) return t; const sideFa = SIDE_LABEL_FA[s] || s; if (!sideFa) return t; return `${t} (${sideFa})`; } const CATALOG_ITEM_BY_ID = new Map(); for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { for (const it of list) { CATALOG_ITEM_BY_ID.set(it.id, it); } } function findCatalogItemByKey(key: string): OuterPartCatalogItem | undefined { for (const list of Object.values(OUTER_PARTS_BY_CAR_TYPE)) { const hit = list.find((x) => x.key === key); if (hit) return hit; } return undefined; } /** Strip leading `left_|right_|…` when present so `name` is side-agnostic for clients. */ export function splitCatalogKeyToNameAndSide( fullKey: string, ): { name: string; side: string } { const t = String(fullKey ?? "").trim(); const i = t.indexOf("_"); if (i <= 0) return { name: t, side: "" }; const head = t.slice(0, i); if (SIDE_SET.has(head)) { return { side: head, name: t.slice(i + 1) }; } return { name: t, side: "" }; } export function catalogLikeKeyFromPart( part: Pick, ): string { if (part.side) return `${part.side}_${part.name}`; return part.name; } export function catalogItemToSelectedPart( item: OuterPartCatalogItem, sameTypeCatalog?: OuterPartCatalogItem[], ): DamageSelectedPartV2 { const { name, side } = splitCatalogKeyToNameAndSide(item.key); const sideEff = String(side || item.side || ""); const list = sameTypeCatalog ?? outerCatalogListForItem(item); const label_fa = disambiguateOuterPartLabelFa(item.titleFa, sideEff, list); return { id: item.id, name, side: sideEff, label_fa, catalogKey: item.key, }; } const LEGACY_SLUG_LABEL_FA: Record = { hood: "کاپوت", trunk: "صندوق عقب", roof: "سقف", front_right_door: "درب جلو راست", front_left_door: "درب جلو چپ", rear_right_door: "درب عقب راست", rear_left_door: "درب عقب چپ", front_bumper: "سپر جلو", rear_bumper: "سپر عقب", front_right_fender: "گلگیر جلو راست", front_left_fender: "گلگیر جلو چپ", rear_right_fender: "گلگیر عقب راست", rear_left_fender: "گلگیر عقب چپ", }; function labelFaForUnknownSlug(slug: string): string { return LEGACY_SLUG_LABEL_FA[slug] || slug; } function toNum(v: unknown): number | null { if (v == null || v === "") return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** * Normalize `damage.selectedParts` from DB (strings, legacy objects, or new objects) * into a stable list for APIs and capture resolution. */ export function normalizeDamageSelectedParts( raw: unknown, carType?: ClaimVehicleTypeV2, selectedOuterPartsLegacy?: unknown, ): DamageSelectedPartV2[] { const arr = Array.isArray(raw) ? raw : []; const legacyOuter = Array.isArray(selectedOuterPartsLegacy) ? selectedOuterPartsLegacy : []; const byKeyFromLegacy = new Map(); for (const o of legacyOuter) { if (o && typeof o === "object" && "key" in (o as object)) { const rec = o as { key?: string; id?: number; side?: string }; const k = String(rec.key || "").trim(); if (k) byKeyFromLegacy.set(k, { id: rec.id, side: rec.side }); } } const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined; const byCatalogKey = catalog ? new Map(catalog.map((c) => [c.key, c])) : undefined; const out: DamageSelectedPartV2[] = []; for (const el of arr) { if (el == null) continue; if (typeof el === "string") { const s = el.trim(); if (!s) continue; let catItem = byCatalogKey?.get(s) ?? findCatalogItemByKey(s); if (!catItem && /^\d+$/.test(s)) { catItem = CATALOG_ITEM_BY_ID.get(Number(s)); } if (catItem) { const list = catalog ?? outerCatalogListForItem(catItem); out.push(catalogItemToSelectedPart(catItem, list)); continue; } const hint = byKeyFromLegacy.get(s); if (hint?.side || hint?.id != null) { const split = splitCatalogKeyToNameAndSide(s); if (split.side) { const sideEff = hint?.side ?? split.side; const baseFa = labelFaForUnknownSlug(s); const list = catalog ?? []; const label_fa = catalog?.length && baseFa ? disambiguateOuterPartLabelFa(baseFa, String(sideEff), catalog) : baseFa; out.push({ id: hint?.id ?? null, name: split.name, side: sideEff, label_fa, catalogKey: s, }); continue; } } out.push({ id: hint?.id ?? null, name: s, side: hint?.side ?? "", label_fa: labelFaForUnknownSlug(s), catalogKey: s, }); continue; } if (typeof el === "object") { const o = el as Record; if (typeof o.name === "string" && o.name.trim()) { let name = o.name.trim(); const inner = splitCatalogKeyToNameAndSide(name); if (inner.side && inner.name) { name = inner.name; } const side = typeof o.side === "string" ? o.side : inner.side || ""; const id = toNum(o.id); const ck = typeof o.catalogKey === "string" && o.catalogKey.trim() ? o.catalogKey.trim() : undefined; let catItem: OuterPartCatalogItem | undefined; if (catalog && byCatalogKey) { if (ck) catItem = byCatalogKey.get(ck); if (!catItem && id != null) catItem = catalog.find((c) => c.id === id); if (!catItem && side && name) { catItem = byCatalogKey.get(catalogLikeKeyFromPart({ side, name })); } } else { if (ck) catItem = findCatalogItemByKey(ck); if (!catItem && id != null) { catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined; } if (!catItem && side && name) { catItem = findCatalogItemByKey( catalogLikeKeyFromPart({ side, name }), ); } } const fallbackLabel = typeof o.label_fa === "string" && o.label_fa.trim() ? o.label_fa.trim() : typeof o.labelFa === "string" && o.labelFa.trim() ? (o.labelFa as string).trim() : name; const listForDis = catalog?.length ? catalog : catItem ? outerCatalogListForItem(catItem) : []; const label_fa = catItem && listForDis.length ? disambiguateOuterPartLabelFa( catItem.titleFa, catItem.side, listForDis, ) : fallbackLabel; out.push({ id, name, side, label_fa, catalogKey: ck, }); continue; } if (typeof o.key === "string" && o.key.trim()) { const k = o.key.trim(); const cat = byCatalogKey?.get(k) ?? findCatalogItemByKey(k) ?? (toNum(o.id) != null ? CATALOG_ITEM_BY_ID.get(toNum(o.id)!) : undefined); if (cat) { const list = catalog ?? outerCatalogListForItem(cat); out.push(catalogItemToSelectedPart(cat, list)); continue; } const split = splitCatalogKeyToNameAndSide(k); out.push({ id: toNum(o.id), name: split.side ? split.name : k, side: typeof o.side === "string" ? o.side : split.side, label_fa: typeof o.label_fa === "string" && o.label_fa.trim() ? (o.label_fa as string).trim() : labelFaForUnknownSlug(k), catalogKey: k, }); } } } return out; } export function partIdentityKey(p: DamageSelectedPartV2): string { if (p.id != null && Number.isFinite(p.id)) return `id:${p.id}`; return `${p.side}|${p.name}`; } /** * Same canonical identity as `partIdentityKey`, but derived directly from a * unified `carPartDamage` snapshot (the shape we persist on * `evaluation.damageExpertReply*.parts[].carPartDamage`). * * Returns `null` when the input cannot produce an identity (no `id`, no * `name`). Callers should treat that as an invalid expert reply line. */ export function partIdentityKeyFromCarPartDamage( carPartDamage: unknown, ): string | null { if ( !carPartDamage || typeof carPartDamage !== "object" || Array.isArray(carPartDamage) ) { return null; } const o = carPartDamage as Record; const idRaw = o.id; const id = typeof idRaw === "number" && Number.isFinite(idRaw) ? idRaw : typeof idRaw === "string" && idRaw.trim() && Number.isFinite(Number(idRaw)) ? Number(idRaw) : null; const name = typeof o.name === "string" ? o.name.trim() : ""; const side = typeof o.side === "string" ? o.side.trim() : ""; if (id == null && !name) return null; return partIdentityKey({ id, name, side, label_fa: "" }); } function normPartSegment(s: string): string { return String(s ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase(); } function looseCatalogNameMatch( catalogItem: OuterPartCatalogItem, expertPart: string, ): boolean { const { name } = splitCatalogKeyToNameAndSide(catalogItem.key); return normPartSegment(name) === normPartSegment(expertPart); } /** * Map legacy expert reply `{ part: "backFender", side: "left" }` to a catalog row * when `carType` is known (or scan all types if omitted). */ export function findCatalogItemFromLegacyExpertPart( part: string, side: string, carType?: ClaimVehicleTypeV2, ): OuterPartCatalogItem | undefined { const s = String(side ?? "").toLowerCase().trim(); const lists = carType ? [OUTER_PARTS_BY_CAR_TYPE[carType]].filter(Boolean) as OuterPartCatalogItem[][] : (Object.values(OUTER_PARTS_BY_CAR_TYPE) as OuterPartCatalogItem[][]); for (const catalog of lists) { if (!catalog?.length) continue; for (const it of catalog) { if (String(it.side).toLowerCase() !== s) continue; if (looseCatalogNameMatch(it, part)) return it; } } return undefined; } function selectedPartToStoredRecord(p: DamageSelectedPartV2): Record { const r: Record = { name: p.name, side: String(p.side || "").toLowerCase(), label_fa: p.label_fa, }; if (p.id != null && Number.isFinite(p.id)) r.id = p.id; if (p.catalogKey) r.catalogKey = p.catalogKey; return r; } /** * Canonical shape for `evaluation.damageExpertReply*.parts[].carPartDamage` * (same fields as owner `damage.selectedParts` items). */ export function normalizeCarPartDamageForExpertReply( raw: unknown, carType?: ClaimVehicleTypeV2, ): Record { if (raw == null) { throw new Error("carPartDamage is required"); } if (typeof raw === "string") { const s = raw.trim(); if (!s) throw new Error("carPartDamage is empty"); const fromKey = findCatalogItemByKey(s); if (fromKey) { const list = outerCatalogListForItem(fromKey); return selectedPartToStoredRecord( catalogItemToSelectedPart(fromKey, list), ); } return { name: s, side: "", label_fa: s }; } if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new Error("Invalid carPartDamage"); } const o = raw as Record; const legacyPart = typeof o.part === "string" && o.part.trim() ? o.part.trim() : ""; const legacySide = typeof o.side === "string" && o.side.trim() ? o.side.trim().toLowerCase() : ""; const name = typeof o.name === "string" && o.name.trim() ? o.name.trim() : ""; const sideIn = typeof o.side === "string" && o.side.trim() ? o.side.trim().toLowerCase() : ""; if (name && sideIn) { const id = toNum(o.id); const ck = typeof o.catalogKey === "string" && o.catalogKey.trim() ? o.catalogKey.trim() : undefined; const catalog = carType ? OUTER_PARTS_BY_CAR_TYPE[carType] : undefined; let catItem: OuterPartCatalogItem | undefined; if (catalog) { const byKey = new Map(catalog.map((c) => [c.key, c])); if (ck) catItem = byKey.get(ck); if (!catItem && id != null) catItem = catalog.find((c) => c.id === id); if (!catItem) { catItem = byKey.get(catalogLikeKeyFromPart({ side: sideIn, name })); } } if (!catItem && id != null) { catItem = CATALOG_ITEM_BY_ID.get(id!) ?? undefined; } const list = catalog?.length ? catalog : catItem ? outerCatalogListForItem(catItem) : []; if (catItem && list.length) { return selectedPartToStoredRecord( catalogItemToSelectedPart(catItem, list), ); } const label_fa = typeof o.label_fa === "string" && o.label_fa.trim() ? o.label_fa.trim() : name; const out: Record = { name, side: sideIn, label_fa, }; if (id != null) out.id = id; if (ck) out.catalogKey = ck; return out; } if (legacyPart && legacySide) { const catItem = findCatalogItemFromLegacyExpertPart( legacyPart, legacySide, carType, ); if (catItem) { const list = outerCatalogListForItem(catItem); return selectedPartToStoredRecord( catalogItemToSelectedPart(catItem, list), ); } const sideFa = SIDE_LABEL_FA[legacySide] || legacySide; return { name: legacyPart, side: legacySide, label_fa: `${legacyPart} (${sideFa})`, }; } throw new Error("carPartDamage must include side and (name or part)"); } /** Best-effort migration for API output; never throws. */ export function migrateExpertReplyCarPartDamageToUnified( raw: unknown, carType?: ClaimVehicleTypeV2, ): Record { try { return normalizeCarPartDamageForExpertReply(raw, carType); } catch { if (raw && typeof raw === "object" && !Array.isArray(raw)) { return { ...(raw as Record) }; } return { name: String(raw ?? ""), side: "", label_fa: String(raw ?? "") }; } } /** Stable string for factor DB rows / logs from unified or legacy `carPartDamage`. */ export function carPartDamageLabelForFactorRecord(cd: unknown): string { if (cd == null) return ""; if (typeof cd === "string") return cd.trim(); if (typeof cd === "object") { const o = cd as Record; if (typeof o.label_fa === "string" && o.label_fa.trim()) { return o.label_fa.trim(); } if (typeof o.part === "string" && o.part.trim()) { const side = typeof o.side === "string" ? o.side.trim() : ""; const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : ""; return sideFa ? `${o.part.trim()} (${sideFa})` : o.part.trim(); } if (typeof o.name === "string" && o.name.trim()) { const side = typeof o.side === "string" ? o.side.trim() : ""; const sideFa = side ? SIDE_LABEL_FA[side.toLowerCase()] || side : ""; return sideFa ? `${o.name.trim()} (${sideFa})` : o.name.trim(); } } return String(cd); } export function mediaRowHasCapture(row: unknown): boolean { if (!row || typeof row !== "object") return false; const r = row as { path?: string; url?: string }; return !!(r.path || r.url); } /** Whether `media.damagedParts` is the new array shape (possibly empty). */ export function isDamagedPartsMediaArray(data: unknown): boolean { return Array.isArray(data); } function getLegacyMapRecord( data: unknown, ): Record | null { if (data == null) return null; if (data instanceof Map) { return Object.fromEntries((data as Map).entries()); } if (typeof data === "object" && !Array.isArray(data)) { return data as Record; } return null; } function getCaptureBlobLoose( legacyMap: Record, partKey: string, ): unknown { const pk = String(partKey ?? "").trim(); if (!pk) return undefined; let b = legacyMap[pk]; if (b) return b; const target = pk.toLowerCase().replace(/[\s_-]/g, ""); for (const k of Object.keys(legacyMap)) { if (k.toLowerCase().replace(/[\s_-]/g, "") === target) { return legacyMap[k]; } } return undefined; } /** * Align legacy `media.damagedParts` map with `selected` order → array rows * (metadata + any existing capture blobs). */ export function migrateLegacyDamagedPartsMapToArray( legacyMap: Record, selected: DamageSelectedPartV2[], ): Record[] { return selected.map((sp) => { const keysTry = [ sp.catalogKey, catalogLikeKeyFromPart(sp), sp.name, ].filter(Boolean) as string[]; let blob: unknown; for (const k of keysTry) { blob = getCaptureBlobLoose(legacyMap, k); if (blob) break; } const base = { id: sp.id ?? undefined, name: sp.name, side: sp.side, label_fa: sp.label_fa, }; if (blob && typeof blob === "object") { const c = blob as Record; return { ...base, ...(c.path ? { path: c.path } : {}), ...(c.fileName ? { fileName: c.fileName } : {}), ...(c.url ? { url: c.url } : {}), ...(c.capturedAt ? { capturedAt: c.capturedAt } : {}), }; } return { ...base }; }); } /** * Always returns an array view: migrates legacy map → array when needed. */ export function coerceDamagedPartsMediaToArray( damagedPartsData: unknown, selected: DamageSelectedPartV2[], ): Record[] { if (Array.isArray(damagedPartsData)) { const arr = damagedPartsData.map((x) => typeof x === "object" && x ? { ...(x as object) } : {}, ) as Record[]; while (arr.length < selected.length) { arr.push({}); } return arr; } const legacy = getLegacyMapRecord(damagedPartsData); if (legacy && selected.length) { return migrateLegacyDamagedPartsMapToArray(legacy, selected); } return []; } /** * Resolve `captureKey` to an index into `selected` / `media.damagedParts`. * * Supported keys: catalog id (number string), array index when unambiguous, * full catalog key (`left_backfender`), or `side_name` matching stored part. */ export function resolvePartCaptureIndex( captureKeyRaw: string, selected: DamageSelectedPartV2[], damagedPartsArray?: Record[], ): number { const raw = String(captureKeyRaw ?? "").trim(); if (!raw) return -1; if (selected.length) { for (let i = 0; i < selected.length; i++) { const sp = selected[i]; if (sp.catalogKey === raw) return i; if (catalogLikeKeyFromPart(sp) === raw) return i; } if (/^\d+$/.test(raw)) { const n = Number(raw); const byId = selected.findIndex((p) => p.id === n); if (byId >= 0) return byId; const hasIdCollision = selected.some((p) => p.id === n); if (!hasIdCollision && n >= 0 && n < selected.length) return n; } const loose = raw.toLowerCase().replace(/[\s_-]/g, ""); for (let i = 0; i < selected.length; i++) { const sp = selected[i]; const cand = [ sp.catalogKey, catalogLikeKeyFromPart(sp), sp.name, ].filter(Boolean) as string[]; for (const c of cand) { if (c.toLowerCase().replace(/[\s_-]/g, "") === loose) return i; } } } if (damagedPartsArray && damagedPartsArray.length) { for (let i = 0; i < damagedPartsArray.length; i++) { const row = damagedPartsArray[i]; if (!row || typeof row !== "object") continue; const id = toNum((row as { id?: unknown }).id); if (id != null && String(id) === raw) return i; const ck = String((row as { catalogKey?: unknown }).catalogKey || ""); if (ck && ck === raw) return i; const name = String((row as { name?: unknown }).name || ""); const side = String((row as { side?: unknown }).side || ""); if (side && name && `${side}_${name}` === raw) return i; } } return -1; } export function mediaRowOrLegacyHasCapture( damagedPartsData: unknown, partKey: string, selected: DamageSelectedPartV2[], ): boolean { if (damagedPartsData == null) return false; if (Array.isArray(damagedPartsData)) { const idx = resolvePartCaptureIndex(partKey, selected, damagedPartsData as any[]); if (idx < 0) return false; return mediaRowHasCapture((damagedPartsData as unknown[])[idx]); } const legacy = getLegacyMapRecord(damagedPartsData); if (!legacy) return false; return !!getCaptureBlobLoose(legacy, partKey); } export function selectedPartsLegacyStringsForAngles( selected: DamageSelectedPartV2[], ): string[] { const s = new Set(); for (const p of selected) { if (p.catalogKey) s.add(p.catalogKey); s.add(catalogLikeKeyFromPart(p)); if (p.name) s.add(p.name); } return [...s]; } /** String keys that might appear under legacy `media.damagedParts` for this selection. */ export function damageSelectedPartsToLookupStrings( raw: unknown, carType?: ClaimVehicleTypeV2, selectedOuterPartsLegacy?: unknown, ): string[] { const norm = normalizeDamageSelectedParts( raw, carType, selectedOuterPartsLegacy, ); if (norm.length) return selectedPartsLegacyStringsForAngles(norm); if (Array.isArray(raw)) { return (raw as unknown[]) .filter((x): x is string => typeof x === "string") .map((x) => x.trim()) .filter(Boolean); } return []; }