feat: конвейер ingest→SQLite, API на БД, фронт сравнения цен

- 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>
This commit is contained in:
2026-06-26 17:30:10 +05:00
parent 1de66eaa85
commit 985faadf36
6 changed files with 438 additions and 24 deletions
+9 -1
View File
@@ -64,6 +64,7 @@ def load_dictionary(path: str | Path) -> list[Service]:
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():
@@ -74,8 +75,15 @@ def load_dictionary(path: str | Path) -> list[Service]:
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=f"{row[col['ID']]}-{row[col['Code']]}",
service_id=sid,
specialty=str(row[col["Специальность"]]).strip() if row[col["Специальность"]] else "",
name_ru=str(raw_name).strip(),
tarificator_code=tar,