forked from Yara724/api
1005 lines
31 KiB
TypeScript
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 [];
|
|
}
|