forked from Yara724/api
YARA-883 + Side ID fixes
This commit is contained in:
@@ -17,6 +17,100 @@ export type DamageSelectedPartV2 = {
|
||||
|
||||
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]: "چپ",
|
||||
@@ -290,36 +384,123 @@ export function normalizeDamageSelectedParts(
|
||||
}
|
||||
}
|
||||
}
|
||||
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}`;
|
||||
return out.map((p) => sanitizeDamageSelectedPartV2(p, carType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client `partId` from claim detail / expert reply to a selected part row.
|
||||
* Accepts `partIdentityKey` values (`id:12`, `left|backfender`), `id:12`, catalog id
|
||||
* when unique, or `index:N` for the Nth entry in `selectedParts`.
|
||||
* 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: string,
|
||||
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();
|
||||
if (!raw || !selected.length) return undefined;
|
||||
|
||||
for (const p of selected) {
|
||||
if (partIdentityKey(p) === raw) return p;
|
||||
}
|
||||
|
||||
const idColon = raw.match(/^id:(\d+)$/i);
|
||||
if (idColon) {
|
||||
const id = Number(idColon[1]);
|
||||
return selected.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
const indexColon = raw.match(/^index:(\d+)$/i);
|
||||
if (indexColon) {
|
||||
const i = Number(indexColon[1]);
|
||||
@@ -328,29 +509,48 @@ export function resolveSelectedPartByPartId(
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const n = Number(raw);
|
||||
const byCatalogId = selected.filter((p) => p.id === n);
|
||||
if (byCatalogId.length === 1) return byCatalogId[0];
|
||||
if (Number.isInteger(n) && n >= 0 && n < selected.length) {
|
||||
return selected[n];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
/** 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,
|
||||
): string | null {
|
||||
): number | null {
|
||||
if (
|
||||
!carPartDamage ||
|
||||
typeof carPartDamage !== "object" ||
|
||||
@@ -359,21 +559,14 @@ export function partIdentityKeyFromCarPartDamage(
|
||||
return null;
|
||||
}
|
||||
const o = carPartDamage as Record<string, unknown>;
|
||||
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: "" });
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user