Files
yara724api/src/helpers/outer-damage-parts.ts
2026-06-03 11:34:21 +03:30

1005 lines
31 KiB
TypeScript

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));
const SIDE_FA_TO_EN: Record<string, OuterPartSideV2> = {
چپ: 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<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>;
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<string, unknown>;
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<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) {
// 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<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 [];
}