From ad5c1913cb24160cc7faa483bca643c7b033cf05 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 28 Jun 2026 15:04:28 +0300 Subject: [PATCH] fix(tradein/v2): address parse (Cyrillic \b bug) + scatter from listing_date (#2038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA on real prod estimate 701795d8 surfaced two mapper defects: - STREET_RE used JS \b which does NOT word-boundary Cyrillic → street token never matched → object.address fell back to the bare house number ('14/3') and city mis-parsed ('14/3, Россия'). Rewrote parseAddress to handle both OSM (house-first) and DaData (city-first) orders; street keyword delimited by space, not \b. Now 'улица Яскина, 14/3' + city 'Екатеринбург'. - days_on_market is null across prod lots → scatter-mini rendered empty. Active analogs now fall back to 'days listed so far' = today-listing_date (deals still require a real days_on_market — today-deal-date is not exposure). TODO BE-1. Verified via tsx harness against the real estimate (25/25); next build green. --- .../src/components/trade-in/v2/mappers.ts | 90 +++++++++++++------ 1 file changed, 64 insertions(+), 26 deletions(-) diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts index 0eace562..b010c498 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts +++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts @@ -327,42 +327,62 @@ function rangeLine( // ── Address parsing (TODO BE-2) ───────────────────────────────────────────── +// NB: JS \b word boundaries do NOT work around Cyrillic (only ASCII \w), so the +// street keyword is delimited by start/space/dot/end explicitly, not \b. const STREET_RE = - /\b(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)\b/i; -const REGION_RE = - /(обл|область|край|респ|республика|р-н|район|округ|\bАО\b)/i; + /(^|\s)(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)(\s|\.|$)/i; const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\s+/i; +// House number token ("14", "14/3", "14А", "14 к2", "14 стр1"); leading дом/зд +// prefix stripped by houseClean before the test. +const HOUSE_RE = /^\d+[а-яА-Я]?(\/\d+)?(\s?к\.?\s?\d+)?(\s?стр\.?\s?\d+)?$/i; +// Administrative anchor — the city token typically sits right before one of these. +const ADMIN_ANCHOR_RE = + /городск(ой|ая)|муниципальн|\bобласть\b|\bобл\b|\bкрай\b|\bреспублик/i; +// Explicit city token "г Екатеринбург" / "город …" / "пгт …" (capture name). +const CITY_TOKEN_RE = /^(?:г|город|пгт|гп)\.?\s+([А-Яа-яЁё].*)/; + /** * Best-effort split of a single address string into {street+house, city}. - * Drops postal index + region parts, finds the street token and treats the - * preceding city-looking token as the city. Falls back to the full string. + * Handles BOTH common geocoder orders (TODO BE-2 — structured address): + * OSM/Nominatim: "14/3, улица Яскина, …, Екатеринбург, городской округ …, область, …" + * DaData: "г Екатеринбург, ул Яскина, д 14/3" */ function parseAddress(full: string | null): { address: string; city: string } { if (!full || !full.trim()) return { address: "—", city: "" }; const parts = full .split(",") .map((s) => s.trim()) - .filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !REGION_RE.test(p)); + .filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !/^Россия$/i.test(p)); if (parts.length === 0) return { address: full.trim(), city: "" }; - let streetStart = parts.findIndex((p) => STREET_RE.test(p)); - let cityName = ""; - if (streetStart > 0) { - for (let i = streetStart - 1; i >= 0; i--) { - if (CITY_PREFIX_RE.test(parts[i]) || i === 0) { - cityName = parts[i].replace(CITY_PREFIX_RE, "").trim(); - break; - } - } - } else if (streetStart === -1) { - // No street token recognised — assume "City, rest…". - cityName = (parts[0] ?? "").replace(CITY_PREFIX_RE, "").trim(); - streetStart = 1; + // City: prefer an explicit г./город token; else the token right before the + // first administrative anchor (городской округ / область / край). + let city = ""; + const tokenMatch = parts.map((p) => p.match(CITY_TOKEN_RE)).find(Boolean); + if (tokenMatch) { + city = tokenMatch[1].trim(); + } else { + const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p)); + if (anchorIdx > 0) city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim(); } - const address = parts.slice(streetStart).join(", ") || full.trim(); - const city = cityName ? `${cityName}, Россия` : ""; + // Address: street token + adjacent house number (house precedes the street in + // OSM order, follows it in DaData order). Fall back to the first part. + const houseClean = (p: string) => + p.replace(/^(?:д|дом|зд|здание)\.?\s+/i, "").trim(); + const streetIdx = parts.findIndex((p) => STREET_RE.test(p)); + let address: string; + if (streetIdx >= 0) { + const street = parts[streetIdx]; + const house = [parts[streetIdx - 1], parts[streetIdx + 1]] + .filter((p): p is string => p != null) + .map(houseClean) + .find((p) => HOUSE_RE.test(p)); + address = house ? `${street}, ${house}` : street; + } else { + address = parts[0]; + } return { address, city }; } @@ -496,13 +516,31 @@ function buildScatterMini( e: AggregatedEstimate, sd?: StreetDealsResponse | null, ): ScatterMini { - const withDom = (lots: AnalogLot[]) => + // Exposure (x-axis). days_on_market is sparsely populated in prod, so for + // ACTIVE analogs fall back to "days listed so far" = today − listing_date + // (a valid lower bound on time-to-sell). Deals keep requiring a real + // days_on_market — today−deal-date is not exposure. TODO BE-1: populate days_on_market. + const daysListed = (l: AnalogLot): number | null => { + if (l.listing_date == null) return null; + const t = new Date(l.listing_date).getTime(); + if (Number.isNaN(t)) return null; + const days = Math.round((Date.now() - t) / 86_400_000); + return days >= 0 && days < 3650 ? days : null; + }; + const realDom = (l: AnalogLot): number | null => + l.days_on_market != null && Number.isFinite(l.days_on_market) + ? l.days_on_market + : null; + const toPts = (lots: AnalogLot[], dom: (l: AnalogLot) => number | null) => lots - .filter((l) => l.days_on_market != null && Number.isFinite(l.price_rub)) - .map((l) => ({ x: l.days_on_market as number, y: l.price_rub })); + .map((l) => ({ x: dom(l), y: l.price_rub })) + .filter( + (p): p is { x: number; y: number } => + p.x != null && Number.isFinite(p.y), + ); - const dealsRaw = withDom(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals); - const analogsRaw = withDom(e.analogs); + const dealsRaw = toPts(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom); + const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l)); const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub; const subjectDays = e.est_days_on_market;