985faadf36
- etl/pipeline.py — extract→normalize→validate→SQLite, дедуп партнёров, версии цен, отчёт качества
- api/main.py — 7 эндпоинтов на SQLite; ядро /services/{id}/partners (сравнение)
- web/index.html — одностраничный фронт (палитра Nomad): поиск, сравнение, дашборд, unmatched
- загрузчик справочника: уникальный service_id
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
4.6 KiB
Python
95 lines
4.6 KiB
Python
"""Загрузчик целевого справочника медицинских услуг.
|
||
|
||
Справочник (`Справочник услуг.xlsx`) — это эталон, к которому нормализуются все
|
||
извлечённые из прайсов позиции. Колонки исходного файла:
|
||
|
||
ID | Специальность | Code | Name_ru | TarificatrCode
|
||
|
||
Синонимов в справочнике нет, поэтому автоматическое сопоставление опирается на
|
||
код тарификатора (точное совпадение) и на эмбеддинги названий. Этот модуль
|
||
отвечает только за загрузку и подготовку индексов; само сопоставление — в
|
||
`etl/normalize`.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from openpyxl import load_workbook
|
||
|
||
# Код тарификатора в формате РК: буква + три группы цифр, например «A02.004.000».
|
||
TARIFICATOR_RE = re.compile(r"^[A-ZА-Я]\d{2}\.\d{3}\.\d{3}$")
|
||
|
||
_WHITESPACE_RE = re.compile(r"\s+")
|
||
|
||
|
||
def normalize_name(text: str | None) -> str:
|
||
"""Привести название услуги к форме для сравнения.
|
||
|
||
Нижний регистр, унификация «ё»→«е», дефисы и кратные пробелы схлопываются в
|
||
один пробел. Исходное написание при этом нигде не теряется — нормализованная
|
||
форма используется только как ключ сопоставления.
|
||
"""
|
||
s = (text or "").strip().lower().replace("ё", "е")
|
||
s = s.replace("-", " ")
|
||
return _WHITESPACE_RE.sub(" ", s).strip()
|
||
|
||
|
||
@dataclass
|
||
class Service:
|
||
"""Одна запись эталонного справочника услуг."""
|
||
|
||
service_id: str # стабильный идентификатор «<ID>-<Code>»
|
||
specialty: str # специальность — служит и категорией, и сужением поиска
|
||
name_ru: str # официальное название
|
||
tarificator_code: str | None # код тарификатора (если задан)
|
||
name_norm: str = field(default="") # нормализованная форма name_ru
|
||
|
||
|
||
def load_dictionary(path: str | Path) -> list[Service]:
|
||
"""Загрузить справочник из xlsx и вернуть список услуг с нормализованными именами.
|
||
|
||
Заголовки ищутся по именам колонок, а не по позиции, — так загрузчик переживёт
|
||
перестановку столбцов. Строки с пустым названием пропускаются.
|
||
"""
|
||
workbook = load_workbook(path, read_only=True, data_only=True)
|
||
sheet = workbook.active
|
||
rows = sheet.iter_rows(values_only=True)
|
||
|
||
header = [str(c).strip() if c is not None else "" for c in next(rows)]
|
||
col = {name: header.index(name) for name in header if name}
|
||
name_idx = col.get("Name_ru")
|
||
if name_idx is None:
|
||
raise ValueError(f"В справочнике нет колонки Name_ru. Заголовки: {header}")
|
||
|
||
services: list[Service] = []
|
||
seen_ids: dict[str, int] = {}
|
||
for row in rows:
|
||
raw_name = row[name_idx]
|
||
if not raw_name or not str(raw_name).strip():
|
||
continue
|
||
|
||
tar_raw = row[col["TarificatrCode"]] if "TarificatrCode" in col else None
|
||
tar = str(tar_raw).strip() if tar_raw else None
|
||
if tar and not TARIFICATOR_RE.match(tar):
|
||
tar = None # отбрасываем мусорные коды, оставляем только валидный формат
|
||
|
||
# «<ID>-<Code>» иногда повторяется в справочнике — гарантируем уникальность.
|
||
sid = f"{row[col['ID']]}-{row[col['Code']]}"
|
||
repeat = seen_ids.get(sid, 0)
|
||
seen_ids[sid] = repeat + 1
|
||
if repeat:
|
||
sid = f"{sid}#{repeat}"
|
||
|
||
service = Service(
|
||
service_id=sid,
|
||
specialty=str(row[col["Специальность"]]).strip() if row[col["Специальность"]] else "",
|
||
name_ru=str(raw_name).strip(),
|
||
tarificator_code=tar,
|
||
)
|
||
service.name_norm = normalize_name(service.name_ru)
|
||
services.append(service)
|
||
|
||
return services
|