"""Загрузчик целевого справочника медицинских услуг. Справочник (`Справочник услуг.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 # стабильный идентификатор «-» 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 # отбрасываем мусорные коды, оставляем только валидный формат # «-» иногда повторяется в справочнике — гарантируем уникальность. 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