ac9f7fea15
- grid: колонки «Код …» и «№» исключены из кандидатов на колонку названий (ключ «услуг» ловил «Код услуги») — клиника с 5181 строкой больше не даёт 0. - vision: авто-добор PDF при пустом или подозрительно низком выходе строк/страницу, замена недобора полным распознаванием, ретраи на лимиты, лимит размера документа. - презентация статуса проекта на /day2; маршрут /day2 в Caddy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
"""Извлечение позиций прайса из «сетки» строк.
|
||
|
||
Алгоритм: найти строку заголовков → определить роли колонок (название, код,
|
||
единица, ценовые тарифы) → пройти строки, отделяя заголовки секций от позиций.
|
||
Один и тот же код работает для xlsx, xls, docx и таблиц, восстановленных из PDF.
|
||
|
||
Распознавание ролей колонок учитывает реальные ловушки прайсов:
|
||
- колонка «№» (последовательные 1, 2, 3…) — это индекс, а не цена;
|
||
- настоящая цена медуслуги — сотни и тысячи тенге, а не мелкое число;
|
||
- название услуги — самая «кириллическая» колонка, код — цифро-точечная.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import statistics
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
|
||
from contracts.models import RawRow # noqa: E402
|
||
from etl.extractors.common import ( # noqa: E402
|
||
_NAME_KEYS,
|
||
_UNIT_KEYS,
|
||
Grid,
|
||
classify_price_tier,
|
||
clean,
|
||
find_header_row,
|
||
is_real_name,
|
||
looks_like_code,
|
||
parse_price,
|
||
)
|
||
|
||
_TIER_ORDER = ["resident", "nonresident", "cis", "far", "insurance", "partner"]
|
||
_CODE_HEADER_KEYS = ("код", "тарификатор")
|
||
_INDEX_HEADER_KEYS = ("№", "п/п", "n п")
|
||
_MIN_REAL_PRICE = 100 # медуслуга дешевле сотни тенге не бывает — отсекает индексы и мелочь
|
||
|
||
|
||
def _col_values(sample: list[list[str]], c: int) -> list[str]:
|
||
return [r[c] for r in sample if c < len(r) and r[c]]
|
||
|
||
|
||
def _col_prices(sample: list[list[str]], c: int) -> list[float]:
|
||
return [p for r in sample if c < len(r) and (p := parse_price(r[c]))]
|
||
|
||
|
||
def _cyrillic_score(sample: list[list[str]], c: int) -> float:
|
||
"""Средняя доля кириллических букв в колонке — признак колонки названий."""
|
||
total = 0
|
||
for r in sample:
|
||
if c < len(r):
|
||
total += sum(1 for ch in r[c] if "а" <= ch.lower() <= "я" or ch.lower() == "ё")
|
||
return total / max(1, len(sample))
|
||
|
||
|
||
def _is_index_column(values: list[float]) -> bool:
|
||
"""Колонка-индекс: целые, монотонные, шаг около 1, старт у единицы."""
|
||
if len(values) < 5:
|
||
return False
|
||
integers = [v for v in values if abs(v - round(v)) < 1e-9]
|
||
if len(integers) < 0.9 * len(values):
|
||
return False
|
||
ordered = sorted(round(v) for v in values)
|
||
steps = [ordered[i + 1] - ordered[i] for i in range(len(ordered) - 1)]
|
||
return ordered[0] <= 3 and statistics.mean(steps) < 1.5
|
||
|
||
|
||
def _assign_columns(grid: Grid, header_idx: int):
|
||
"""Определить роли колонок по заголовку и выборке строк под ним."""
|
||
header = [clean(c) for c in grid[header_idx]]
|
||
ncol = max((len(r) for r in grid), default=0)
|
||
header += [""] * (ncol - len(header))
|
||
sample = [[clean(c) for c in r] for r in grid[header_idx + 1 : header_idx + 60]]
|
||
|
||
# --- ценовые колонки ---
|
||
price_cols: dict[int, str | None] = {}
|
||
for c in range(ncol):
|
||
head = header[c].lower()
|
||
if any(k in head for k in _INDEX_HEADER_KEYS) or any(k in head for k in _CODE_HEADER_KEYS):
|
||
continue # «№» и «Код» — не цены
|
||
prices = _col_prices(sample, c)
|
||
nonempty = _col_values(sample, c)
|
||
ratio = len(prices) / len(nonempty) if nonempty else 0
|
||
if not prices or _is_index_column(prices):
|
||
continue
|
||
tier = classify_price_tier(header[c])
|
||
if tier:
|
||
price_cols[c] = tier
|
||
elif ratio >= 0.6 and statistics.median(prices) >= _MIN_REAL_PRICE:
|
||
price_cols[c] = None # числовая колонка без явного тарифа — назначим по позиции
|
||
|
||
used = {t for t in price_cols.values() if t}
|
||
fallback = [t for t in _TIER_ORDER if t not in used]
|
||
for c in sorted(price_cols):
|
||
if price_cols[c] is None:
|
||
price_cols[c] = fallback.pop(0) if fallback else f"extra_{c}"
|
||
|
||
# --- колонка названия: явный заголовок, иначе самая кириллическая ---
|
||
# Колонки кода/индекса исключаем заранее: иначе ключ «услуг» из _NAME_KEYS ловит
|
||
# заголовок «Код услуги» и колонка кодов ошибочно становится колонкой названий.
|
||
def _is_code_or_index(c: int) -> bool:
|
||
return any(k in header[c].lower() for k in _CODE_HEADER_KEYS + _INDEX_HEADER_KEYS)
|
||
|
||
name_col = next(
|
||
(
|
||
c
|
||
for c in range(ncol)
|
||
if c not in price_cols
|
||
and not _is_code_or_index(c)
|
||
and any(k in header[c].lower() for k in _NAME_KEYS)
|
||
),
|
||
None,
|
||
)
|
||
if name_col is None:
|
||
candidates = [c for c in range(ncol) if c not in price_cols and not _is_code_or_index(c)]
|
||
name_col = max(candidates, key=lambda c: _cyrillic_score(sample, c)) if candidates else 0
|
||
|
||
# --- колонка кода: заголовок «Код»/«тарификатор» или код-подобные значения ---
|
||
code_col = next(
|
||
(
|
||
c
|
||
for c in range(ncol)
|
||
if c != name_col
|
||
and c not in price_cols
|
||
and any(k in header[c].lower() for k in _CODE_HEADER_KEYS)
|
||
),
|
||
None,
|
||
)
|
||
if code_col is None:
|
||
for c in range(ncol):
|
||
if c == name_col or c in price_cols:
|
||
continue
|
||
vals = _col_values(sample, c)
|
||
if vals and sum(1 for v in vals if looks_like_code(v)) >= max(2, 0.5 * len(vals)):
|
||
code_col = c
|
||
break
|
||
|
||
unit_col = next(
|
||
(
|
||
c
|
||
for c in range(ncol)
|
||
if c not in price_cols and any(k in header[c].lower() for k in _UNIT_KEYS)
|
||
),
|
||
None,
|
||
)
|
||
return name_col, code_col, unit_col, price_cols
|
||
|
||
|
||
def extract_rows_from_grid(grid: Grid) -> list[RawRow]:
|
||
"""Превратить сетку в список позиций прайса."""
|
||
grid = [[clean(c) for c in r] for r in grid if any(clean(c) for c in r)]
|
||
if len(grid) < 2:
|
||
return []
|
||
|
||
header_idx = find_header_row(grid)
|
||
name_col, code_col, unit_col, price_cols = _assign_columns(grid, header_idx)
|
||
|
||
rows: list[RawRow] = []
|
||
section: str | None = None
|
||
for r in grid[header_idx + 1 :]:
|
||
nonempty = [c for c in r if c]
|
||
if not nonempty:
|
||
continue
|
||
|
||
prices: dict[str, float] = {}
|
||
for c, tier in price_cols.items():
|
||
if c < len(r):
|
||
price = parse_price(r[c])
|
||
if price:
|
||
prices[tier] = price
|
||
|
||
# Заголовок секции: одна осмысленная ячейка и ни одной цены.
|
||
if not prices and len(nonempty) == 1:
|
||
section = nonempty[0]
|
||
continue
|
||
|
||
name = r[name_col] if name_col < len(r) else ""
|
||
if not prices or not is_real_name(name):
|
||
continue # не позиция: нет цены либо в «названии» код/число, а не услуга
|
||
|
||
rows.append(
|
||
RawRow(
|
||
service_name_raw=name,
|
||
service_code_source=(
|
||
r[code_col]
|
||
if code_col is not None and code_col < len(r) and r[code_col]
|
||
else None
|
||
),
|
||
prices=prices,
|
||
unit=(
|
||
r[unit_col]
|
||
if unit_col is not None and unit_col < len(r) and r[unit_col]
|
||
else None
|
||
),
|
||
section=section,
|
||
)
|
||
)
|
||
return rows
|