chore: каркас проекта — контракты, справочник услуг, синтетические фикстуры
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user