chore: каркас проекта — контракты, справочник услуг, синтетические фикстуры

This commit is contained in:
2026-06-26 12:30:00 +05:00
commit 3834e4811d
18 changed files with 3409 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
"""Загрузчик целевого справочника медицинских услуг.
Справочник (`Справочник услуг.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