YARA-883 + Side ID fixes

This commit is contained in:
SepehrYahyaee
2026-05-18 17:14:45 +03:30
parent e1954cdb37
commit cef684e37f
12 changed files with 1234 additions and 119 deletions

View File

@@ -0,0 +1,83 @@
import {
buildCoefficientsFromPartSeverities,
calculateClaimPriceDrop,
resolvePriceDropPartKeyFromDamagePart,
resolveVehicleModelYearFromBlame,
} from "./claim-price-drop";
describe("claim-price-drop", () => {
it("maps catalog segment to price drop key", () => {
expect(
resolvePriceDropPartKeyFromDamagePart({
name: "backfender",
catalogKey: "left_backfender",
}),
).toBe("backFender");
expect(
resolvePriceDropPartKeyFromDamagePart({
name: "hood",
catalogKey: "front_carHood",
}),
).toBe("Hood");
});
it("calculates total from formula", () => {
const r = calculateClaimPriceDrop(400_000_000, 1394, [2, 3]);
expect(r.sumOfSeverity).toBe(5);
expect(r.coefficientYear).toBe(2.1);
expect(r.total).toBe((400_000_000 * 2.1 * 5) / 400);
});
it("reads model year from blame inquiry", () => {
const year = resolveVehicleModelYearFromBlame(
{ owner: { userId: "u1" } },
{
parties: [
{
role: "FIRST",
person: { userId: "u1" },
vehicle: {
inquiry: { mapped: { ModelField: "1395" } },
},
},
],
},
);
expect(year).toBe(1395);
});
it("builds coefficients from expert severities", () => {
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
[
{
id: 1,
name: "backfender",
side: "left",
label_fa: "گلگیر",
catalogKey: "left_backfender",
},
],
[{ partId: 1, severity: "Moderate" }],
);
expect(errors).toHaveLength(0);
expect(coefficients).toEqual([3]);
});
it("accepts numeric catalog id (e.g. 201)", () => {
const { coefficients, errors, lines } = buildCoefficientsFromPartSeverities(
[
{
id: 201,
name: "backfender",
side: "left",
label_fa: "گلگیر عقب (چپ)",
catalogKey: "left_backfender",
},
],
[{ partId: 201, severity: "Moderate" }],
);
expect(errors).toHaveLength(0);
expect(coefficients).toEqual([3]);
expect(lines[0]?.partId).toBe(201);
});
});

View File

@@ -0,0 +1,349 @@
import {
ClaimVehicleTypeV2,
} from "src/static/outer-car-parts-catalog";
import {
catalogPartIdFromSelectedPart,
parseCatalogPartIdInput,
resolvePartForExpertReply,
type DamageSelectedPartV2,
} from "src/helpers/outer-damage-parts";
/** Canonical severity values for expert input (API). */
export type PriceDropSeverity = "Minor" | "Moderate" | "Severe";
export const PRICE_DROP_SEVERITY_FA: Record<PriceDropSeverity, string> = {
Minor: "جزیی",
Moderate: "متوسط",
Severe: "شدید",
};
export const PRICE_DROP_SEVERITY_OPTIONS = (
Object.entries(PRICE_DROP_SEVERITY_FA) as [PriceDropSeverity, string][]
).map(([value, label_fa]) => ({ value, label_fa }));
/** Part → severity → coefficient (same table as V1 expert-claim). */
export const PRICE_DROP_PART_TABLE: Record<
string,
Partial<Record<string, number>>
> = {
backFender: { Minor: 2, Moderate: 3, Severe: 5 },
backDoor: { Minor: 1, Moderate: 2, Severe: 3 },
frontDoor: { Minor: 1, Moderate: 2, Severe: 3 },
frontFender: { Minor: 1, Moderate: 2, Severe: 3 },
frontBumper: { Minor: 1, Moderate: 2, Severe: 3 },
Hood: { Minor: 2, moderate: 3, Severe: 4 },
Trunk: { minor: 1, moderate: 3, Severe: 5 },
Roof: { Minor: 3, Moderate: 5, Severe: 7 },
coil: { Minor: 2, Moderate: 3, Severe: 4 },
column: { Minor: 2, Moderate: 3, Severe: 4 },
frontTray: { Minor: 1, Moderate: 2, Severe: 3 },
backTray: { Minor: 2, moderate: 4, Severe: 5 },
frontChassis: { Minor: 3, Moderate: 5, Severe: 7 },
backChassis: { Minor: 2, Moderate: 4, Severe: 6 },
carFootrest: { Minor: 1, Moderate: 2, Severe: 3 },
carFloor: { Minor: 4, moderate: 6, Severe: 8 },
};
/** Jalali model year → year coefficient (V1). */
export const PRICE_DROP_YEAR_COEFFICIENT: Record<number, number> = {
1393: 2.05,
1394: 2.1,
1395: 2.2,
1396: 2.3,
1397: 2.4,
1398: 2.5,
1399: 2.6,
1400: 2.7,
1401: 2.8,
1402: 2.9,
1403: 3,
1404: 3,
};
const PRICE_DROP_PART_LABEL_FA: Record<string, string> = {
backFender: "گلگیر عقب",
backDoor: "در عقب",
frontDoor: "در جلو",
frontFender: "گلگیر جلو",
frontBumper: "سپر جلو",
Hood: "کاپوت",
Trunk: "صندوق عقب",
Roof: "سقف",
coil: "لوله",
column: "ستون",
frontTray: "سینی جلو",
backTray: "سینی عقب",
frontChassis: "شاسی جلو",
backChassis: "شاسی عقب",
carFootrest: "پا رکاب",
carFloor: "کف خودرو",
};
/** Catalog / damage `name` segments → price-drop part key. */
const SEGMENT_ALIASES: Record<string, string> = {
backfender: "backFender",
backdoor: "backDoor",
frontdoor: "frontDoor",
frontfender: "frontFender",
frontbumper: "frontBumper",
backbumper: "frontBumper",
carhood: "Hood",
hood: "Hood",
cartrunk: "Trunk",
trunk: "Trunk",
roof: "Roof",
coil: "coil",
column: "column",
fronttray: "frontTray",
backtray: "backTray",
frontchassis: "frontChassis",
backchassis: "backChassis",
carfootrest: "carFootrest",
carfloor: "carFloor",
};
const NORM_TO_PART_KEY = new Map<string, string>();
for (const key of Object.keys(PRICE_DROP_PART_TABLE)) {
NORM_TO_PART_KEY.set(normalizePriceDropKey(key), key);
}
for (const [seg, key] of Object.entries(SEGMENT_ALIASES)) {
NORM_TO_PART_KEY.set(seg, key);
}
export function normalizePriceDropKey(str: string): string {
return String(str ?? "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
export function parsePriceDropNumber(input: number | string): number {
if (typeof input === "number" && Number.isFinite(input)) return input;
return Number(
String(input)
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
.replace(/,/g, "")
.trim(),
);
}
export function resolveSeverityCoefficient(
partKey: string,
severity: string,
): number | undefined {
const row = PRICE_DROP_PART_TABLE[partKey];
if (!row) return undefined;
const sev = String(severity ?? "").trim();
if (row[sev] !== undefined) return row[sev];
const cap = sev.charAt(0).toUpperCase() + sev.slice(1).toLowerCase();
if (row[cap] !== undefined) return row[cap];
const low = sev.toLowerCase();
if (row[low] !== undefined) return row[low];
return undefined;
}
export function calculateClaimPriceDrop(
carPrice: number | string,
carModelYear: number,
coefficients: number[],
): {
carPrice: number;
carModel: number;
carValue: number[];
sumOfSeverity: number;
coefficientYear: number;
total: number;
} {
const normalizePrice = parsePriceDropNumber(carPrice);
const coefficient = PRICE_DROP_YEAR_COEFFICIENT[carModelYear] ?? 1;
const carValue = coefficients.filter((n) => Number.isFinite(n));
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
const total = (normalizePrice * coefficient * sumOfSeverity) / 400;
return {
carPrice: normalizePrice,
carModel: carModelYear,
carValue,
sumOfSeverity,
coefficientYear: coefficient,
total: total || 0,
};
}
export function buildPriceDropCatalogForApi(): Array<{
key: string;
label_fa: string;
severities: Record<PriceDropSeverity, number | undefined>;
}> {
return Object.keys(PRICE_DROP_PART_TABLE).map((key) => {
const row = PRICE_DROP_PART_TABLE[key];
return {
key,
label_fa: PRICE_DROP_PART_LABEL_FA[key] ?? key,
severities: {
Minor: resolveSeverityCoefficient(key, "Minor"),
Moderate: resolveSeverityCoefficient(key, "Moderate"),
Severe: resolveSeverityCoefficient(key, "Severe"),
},
};
});
}
export function resolvePriceDropPartKeyFromDamagePart(part: {
name?: string;
catalogKey?: string;
}): string | null {
const candidates: string[] = [];
if (part.catalogKey) {
for (const seg of String(part.catalogKey).split("_")) {
if (seg) candidates.push(seg);
}
}
if (part.name) candidates.push(String(part.name));
for (const raw of candidates) {
const norm = normalizePriceDropKey(raw);
const hit = NORM_TO_PART_KEY.get(norm);
if (hit && PRICE_DROP_PART_TABLE[hit]) return hit;
}
return null;
}
export function buildDamagedPartsPriceDropLines(
selectedParts: DamageSelectedPartV2[],
): Array<{
partId: number | null;
name: string;
side: string;
label_fa: string;
priceDropPartKey: string | null;
mappable: boolean;
}> {
return selectedParts.map((sp) => {
const priceDropPartKey = resolvePriceDropPartKeyFromDamagePart(sp);
return {
partId: catalogPartIdFromSelectedPart(sp),
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
priceDropPartKey,
mappable: priceDropPartKey != null,
};
});
}
/** Resolve Jalali year from linked blame party inquiry (owner party preferred). */
export function resolveVehicleModelYearFromBlame(
claim: { owner?: { userId?: unknown } },
blame: {
parties?: Array<{
role?: string;
person?: { userId?: unknown };
vehicle?: {
inquiry?: {
mapped?: { ModelField?: unknown; ModelCii?: unknown };
ModelField?: unknown;
ModelCii?: unknown;
};
};
}>;
} | null,
): number | null {
if (!blame?.parties?.length) return null;
const ownerId =
claim.owner?.userId != null ? String(claim.owner.userId) : "";
let party =
blame.parties.find(
(p) =>
ownerId &&
p.person?.userId != null &&
String(p.person.userId) === ownerId,
) ?? blame.parties.find((p) => p.role === "FIRST");
if (!party) party = blame.parties[0];
const inquiry = party?.vehicle?.inquiry;
const mapped = inquiry?.mapped ?? inquiry;
const raw =
(mapped as { ModelField?: unknown })?.ModelField ??
(mapped as { ModelCii?: unknown })?.ModelCii;
if (raw == null || raw === "") return null;
const n =
typeof raw === "number" ? raw : parseInt(String(raw).trim(), 10);
return Number.isFinite(n) ? n : null;
}
export interface PriceDropPartSeverityInput {
partId: number | string;
severity: PriceDropSeverity;
}
export function buildCoefficientsFromPartSeverities(
selectedParts: DamageSelectedPartV2[],
partSeverities: PriceDropPartSeverityInput[],
carType?: ClaimVehicleTypeV2,
): {
coefficients: number[];
lines: Array<{
partId: number;
priceDropPartKey: string;
label_fa: string;
severity: PriceDropSeverity;
coefficient: number;
}>;
errors: string[];
} {
const coefficients: number[] = [];
const lines: Array<{
partId: number;
priceDropPartKey: string;
label_fa: string;
severity: PriceDropSeverity;
coefficient: number;
}> = [];
const errors: string[] = [];
for (const row of partSeverities) {
const catalogId = parseCatalogPartIdInput(row.partId);
if (catalogId == null) {
errors.push(`Unknown partId "${row.partId}".`);
continue;
}
const sp = resolvePartForExpertReply(
catalogId,
selectedParts,
carType,
);
if (!sp) {
errors.push(`Unknown partId "${row.partId}".`);
continue;
}
const partKey = resolvePriceDropPartKeyFromDamagePart(sp);
if (!partKey) {
errors.push(
`Part "${row.partId}" (${sp.label_fa}) has no price-drop mapping.`,
);
continue;
}
const coef = resolveSeverityCoefficient(partKey, row.severity);
if (coef === undefined) {
errors.push(
`Invalid severity "${row.severity}" for part key "${partKey}".`,
);
continue;
}
coefficients.push(coef);
const linePartId = catalogPartIdFromSelectedPart(sp);
if (linePartId == null) {
errors.push(`Part "${row.partId}" has no catalog id.`);
continue;
}
lines.push({
partId: linePartId,
priceDropPartKey: partKey,
label_fa: sp.label_fa,
severity: row.severity,
coefficient: coef,
});
}
return { coefficients, lines, errors };
}

View File

@@ -4,7 +4,8 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
export interface ClaimPricingPartLite {
factorNeeded?: boolean;
partId?: string;
/** Numeric catalog id (legacy string values may still exist in old documents). */
partId?: number | string;
}
export function getActiveV2ExpertReply(claim: { evaluation?: Record<string, unknown> }): {

View File

@@ -1,6 +1,11 @@
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { buildCoefficientsFromPartSeverities } from "./claim-price-drop";
import {
partIdentityKey,
catalogPartIdFromSelectedPart,
resolveCatalogPartByPartId,
resolvePartForExpertReply,
resolveSelectedPartByPartId,
sanitizeDamageSelectedPartV2,
type DamageSelectedPartV2,
} from "./outer-damage-parts";
@@ -21,17 +26,97 @@ describe("resolveSelectedPartByPartId", () => {
},
];
it("resolves by partIdentityKey", () => {
expect(
resolveSelectedPartByPartId(partIdentityKey(selected[0]), selected),
).toBe(selected[0]);
it("resolves by numeric catalog id", () => {
expect(resolveSelectedPartByPartId(12, selected)).toBe(selected[0]);
});
it("resolves by id:N catalog prefix", () => {
it("resolves legacy id:N string input", () => {
expect(resolveSelectedPartByPartId("id:12", selected)).toBe(selected[0]);
});
it("resolves by index:N", () => {
expect(resolveSelectedPartByPartId("index:1", selected)).toBe(selected[1]);
});
it("exposes catalog id as numeric partId", () => {
expect(catalogPartIdFromSelectedPart(selected[0])).toBe(12);
});
it("resolves catalog id from outer catalog when not on claim", () => {
const hit = resolveCatalogPartByPartId(201, ClaimVehicleTypeV2.HATCHBACK);
expect(hit?.id).toBe(201);
expect(hit?.side).toBe("left");
expect(hit?.catalogKey).toBe("left_backfender");
});
it("resolvePartForExpertReply uses catalog for new expert line", () => {
const hit = resolvePartForExpertReply(
201,
[],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(hit?.id).toBe(201);
});
it("sanitize fixes Persian side and re-hydrates from catalog id", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: 201,
name: "گلگیر عقب (چپ)",
side: "چپ",
label_fa: "",
catalogKey: "چپ",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
expect(catalogPartIdFromSelectedPart(fixed)).toBe(201);
});
it("internal parts have null catalog partId", () => {
const row = sanitizeDamageSelectedPartV2({
id: 99,
name: "قاب موتور",
side: "internal",
label_fa: "قاب موتور",
});
expect(row.id).toBeNull();
expect(catalogPartIdFromSelectedPart(row)).toBeNull();
});
it("recovers outer part wrongly stored as internal with Persian label", () => {
const fixed = sanitizeDamageSelectedPartV2(
{
id: null,
name: "گلگیر عقب (چپ)",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
},
ClaimVehicleTypeV2.HATCHBACK,
);
expect(fixed.id).toBe(201);
expect(fixed.side).toBe("left");
expect(fixed.name).toBe("backfender");
expect(fixed.catalogKey).toBe("left_backfender");
});
it("price drop resolves catalog id when claim row is corrupt", () => {
const corrupt: DamageSelectedPartV2[] = [
{
id: null,
name: "گلگیر عقب (چپ)",
side: "internal",
label_fa: "گلگیر عقب (چپ)",
},
];
const { coefficients, errors } = buildCoefficientsFromPartSeverities(
corrupt,
[{ partId: 201, severity: "Minor" }],
ClaimVehicleTypeV2.HATCHBACK,
);
expect(errors).toHaveLength(0);
expect(coefficients).toEqual([2]);
});
});

View File

@@ -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();
}