"""Эвристики извлечения, общие для всех форматов. Все табличные источники (xlsx, xls, docx, таблицы из PDF) сводятся к «сетке» — списку строк из строковых ячеек — и обрабатываются единым кодом. Здесь собраны функции, понимающие реальные особенности казахстанских прайсов: разрядные пробелы в числах, многотарифные колонки цен, строки-заголовки секций и шапку не в первой строке таблицы. """ from __future__ import annotations import re Grid = list[list[str]] # Ячейка-цена целиком: «14 400», «12600.0», «3 980», «9000 тг», «от 12000». # Требуем, чтобы вся ячейка была числом (плюс валюта или «от»), иначе цифра внутри # текста («Раздел 1.Консультации») ошибочно сошла бы за цену. _PRICE_CELL_RE = re.compile( r"^(?:от\s+)?(\d[\d .,]*\d|\d)\s*(?:тг|тенге|₸|kzt|руб|\$)?\.?$", re.IGNORECASE, ) # Похоже на код услуги: «U1.1», «A02.004.000», «1.0», «В02.110.002». _CODE_RE = re.compile(r"^[A-ZА-Я]?\.?\d+([.\-]\d+)*[A-ZА-Я]?$") _NAME_KEYS = ("наименование", "услуг", "название", "перечень") _PRICE_KEYS = ("цена", "стоимость", "тариф", "тенге") _UNIT_KEYS = ("ед.", "единица", "ед изм", "ед. изм") _CODE_KEYS = ("код", "№", "тарификатор") # Заголовок ценовой колонки → тип тарифа. Порядок важен: частные случаи раньше общих. _TIER_PATTERNS: list[tuple[str, tuple[str, ...]]] = [ ("nonresident", ("нерезидент", "без гражданства", "иностран")), ("far", ("дальнего", "дальнее")), ("cis", ("снг", "ближнего", "оралман")), ("insurance", ("страхов",)), ("partner", ("партнер", "партнён", "партнёр")), ("resident", ("резидент", "республики казахстан", " рк", "граждан республики")), ] def clean(text) -> str: """Строковое значение ячейки без хвостовых пробелов и переводов строк.""" if text is None: return "" return re.sub(r"\s+", " ", str(text)).strip() def parse_price(text) -> float | None: """Вытащить цену из ячейки, если ВСЯ ячейка — число (плюс валюта или «от»). Возвращает положительный float или None. Намеренно строгая: «Раздел 1.…» или «приём (30 минут)» ценой не считаются — это защищает детектор секций. """ s = clean(text) if not s: return None m = _PRICE_CELL_RE.match(s) if not m: return None raw = m.group(1).replace(" ", "") # Есть и точка, и запятая — запятая разрядная; иначе запятая = десятичная. if "," in raw and "." in raw: raw = raw.replace(",", "") else: raw = raw.replace(",", ".") try: value = float(raw) except ValueError: return None return value if value > 0 else None def looks_like_code(text) -> bool: """Похоже ли значение на код услуги (внутренний или тарификатора).""" s = clean(text) return bool(s) and len(s) <= 16 and bool(_CODE_RE.match(s)) def is_real_name(text) -> bool: """Похоже ли значение на настоящее название услуги, а не на код или число. Защищает нормализацию от мусора: коды («E04.000.012»), номера и цены, по ошибке попавшие в колонку названия, не должны уходить в сопоставление со справочником. Требуем минимум три кириллические буквы и не код-подобную форму. """ s = clean(text) cyrillic = sum(1 for ch in s if "а" <= ch.lower() <= "я" or ch.lower() == "ё") if cyrillic < 3 or looks_like_code(s): return False # Код-мнемоника: цифры + только заглавные буквы + без пробела (напр. «МОЗО.15») — не услуга. if any(c.isdigit() for c in s) and " " not in s and not any(c.islower() for c in s): return False return True def classify_price_tier(header_text) -> str | None: """По тексту заголовка ценовой колонки определить тип тарифа.""" h = clean(header_text).lower() if not h: return None for tier, keys in _TIER_PATTERNS: if any(k in h for k in keys): return tier if any(k in h for k in _PRICE_KEYS): return "resident" # обобщённая «Цена»/«Стоимость» — основной тариф return None def _has_key(cells: list[str], keys: tuple[str, ...]) -> bool: joined = " | ".join(c.lower() for c in cells) return any(k in joined for k in keys) def find_header_row(grid: Grid, scan: int = 30) -> int: """Найти индекс строки заголовков (она не всегда первая). Заголовок — строка, где есть и колонка названия услуги, и хотя бы одна ценовая колонка. Если явной шапки нет, берётся первая строка, под которой идут пары «текст + число». """ best_idx, best_score = -1, 0 for i, row in enumerate(grid[:scan]): cells = [clean(c) for c in row] if not any(cells): continue score = 0 if _has_key(cells, _NAME_KEYS): score += 2 score += sum(1 for c in cells if classify_price_tier(c)) if _has_key(cells, _UNIT_KEYS): score += 1 if score > best_score: best_idx, best_score = i, score if best_idx >= 0 and best_score >= 2: return best_idx # Фолбэк: первая строка, под которой есть пара «непустой текст + число». for i in range(min(scan, len(grid))): for j in range(i + 1, min(i + 6, len(grid))): cells = [clean(c) for c in grid[j]] texts = [c for c in cells if c and not parse_price(c)] nums = [c for c in cells if parse_price(c)] if texts and nums: return i return 0