1
0
forked from Yara724/api

Fixed claim/blame damagedParts

This commit is contained in:
2026-05-03 13:54:48 +03:30
parent c2d59112cf
commit c579f8fa1d
16 changed files with 1347 additions and 299 deletions

View File

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