feat: API и веб-интерфейс — поиск, сравнение цен, экран верификации, загрузка архива

This commit is contained in:
2026-06-26 22:40:00 +05:00
parent e03e70535e
commit 969d958ec3
5 changed files with 711 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
"""MedArchive API — эндпоинты ТЗ §4.5 плюс /stats для дашборда.
Читает базу, собранную конвейером (etl/pipeline.py). Для MVP это SQLite; путь
задаётся переменной окружения MEDARCHIVE_DB. Ядро WOW — /services/{id}/partners:
«кто оказывает услугу и по какой цене», отсортировано от выгодной.
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import sys
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from contracts.models import PartnerOut, PriceOut, ServiceOut, Stats # noqa: E402
DB_PATH = os.environ.get(
"MEDARCHIVE_DB", str(Path(__file__).resolve().parents[1] / "data/medarchive.db")
)
app = FastAPI(
title="MedArchive API",
version="1.0.0",
description="Поиск услуг и цен по архиву прайсов клиник-партнёров.",
# За Caddy сервис живёт на /api — без этого Swagger ищет спеку в корне и падает.
root_path="/api",
)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
# Отложенные оператором позиции (чтобы пропущенное не возвращалось в начало очереди).
conn.execute("CREATE TABLE IF NOT EXISTS skipped_item (item_id INTEGER PRIMARY KEY)")
return conn
_WS = re.compile(r"\s+")
def _norm(text: str | None) -> str:
"""Нормализованная форма названия (для запоминания синонима при верификации)."""
return _WS.sub(" ", (text or "").strip().lower().replace("ё", "е").replace("-", " ")).strip()
def _price_out(row: sqlite3.Row) -> PriceOut:
return PriceOut(
partner_id=row["partner_id"],
partner_name=row["partner_name"],
price_resident_kzt=row["price_resident"],
price_nonresident_kzt=row["price_nonresident"],
prices=json.loads(row["prices"] or "{}"),
effective_date=row["effective_date"],
)
@app.get("/services", response_model=list[ServiceOut], summary="Услуги справочника")
def list_services(specialty: str | None = None, q: str | None = None, limit: int = 100):
sql = "SELECT service_id, specialty, name_ru, tarificator_code FROM service WHERE 1=1"
args: list = []
if specialty:
sql += " AND specialty = ?"
args.append(specialty)
if q:
# Поиск по нормализованной колонке: SQLite LIKE сворачивает регистр только для
# латиницы, поэтому «УЗИ» и «узи» совпадут лишь при сравнении lowercase-форм.
sql += " AND name_norm LIKE ?"
args.append(f"%{_norm(q)}%")
sql += " ORDER BY name_ru LIMIT ?"
args.append(limit)
with db() as conn:
return [ServiceOut(**dict(r)) for r in conn.execute(sql, args)]
@app.get(
"/services/{service_id}/partners",
response_model=list[PriceOut],
summary="Кто оказывает услугу и по какой цене",
)
def service_partners(service_id: str):
"""Одна строка на клинику с показательной ценой (резидент или минимальный тариф)."""
sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident,
pi.prices, pi.effective_date
FROM price_item pi JOIN partner p ON pi.partner_id = p.partner_id
WHERE pi.service_id = ? AND pi.is_active = 1"""
best: dict[str, PriceOut] = {}
with db() as conn:
for r in conn.execute(sql, [service_id]):
prices = json.loads(r["prices"] or "{}")
display = r["price_resident"] or (min(prices.values()) if prices else None)
current = best.get(r["partner_id"])
if current is None or (
display is not None
and (current.price_resident_kzt is None or display < current.price_resident_kzt)
):
best[r["partner_id"]] = PriceOut(
partner_id=r["partner_id"],
partner_name=r["partner_name"],
price_resident_kzt=display,
price_nonresident_kzt=r["price_nonresident"],
prices=prices,
effective_date=r["effective_date"],
)
return sorted(best.values(), key=lambda x: x.price_resident_kzt or float("inf"))
@app.get("/partners", response_model=list[PartnerOut], summary="Партнёры")
def list_partners(city: str | None = None):
sql = "SELECT partner_id, name, city FROM partner WHERE 1=1"
args: list = []
if city:
sql += " AND city = ?"
args.append(city)
sql += " ORDER BY name"
with db() as conn:
return [
PartnerOut(partner_id=r["partner_id"], name=r["name"], city=r["city"])
for r in conn.execute(sql, args)
]
@app.get(
"/partners/{partner_id}/services",
response_model=list[PriceOut],
summary="Все услуги партнёра с ценами",
)
def partner_services(partner_id: str, limit: int = 500):
sql = """SELECT pi.partner_id, p.name AS partner_name, pi.price_resident, pi.price_nonresident,
pi.prices, pi.effective_date
FROM price_item pi JOIN partner p ON pi.partner_id = p.partner_id
WHERE pi.partner_id = ? AND pi.is_active = 1 LIMIT ?"""
with db() as conn:
return [_price_out(r) for r in conn.execute(sql, [partner_id, limit])]
@app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам")
def search(q: str = Query(..., min_length=1), limit: int = 20):
# Сравниваем по name_norm (lowercase): иначе кириллический регистр («УЗИ» vs «узи»)
# не сворачивается и часть запросов даёт «ничего не найдено».
like = f"%{_norm(q)}%"
with db() as conn:
services = [
dict(r)
for r in conn.execute(
"SELECT service_id, specialty, name_ru FROM service WHERE name_norm LIKE ? LIMIT ?",
[like, limit],
)
]
partners = [
dict(r)
for r in conn.execute(
"SELECT partner_id, name, city FROM partner WHERE name_norm LIKE ? LIMIT ?",
[like, limit],
)
]
return {"services": services, "partners": partners}
@app.get("/unmatched", summary="Очередь верификации: позиции с предложенным кандидатом")
def unmatched(limit: int = 50, offset: int = 0):
"""Несопоставленные позиции с лучшим кандидатом из справочника (сортировка — по уверенности)."""
sql = """SELECT pi.item_id, pi.service_name_raw, pi.prices, pi.unit, p.name AS partner_name,
pi.suggested_service_id, pi.suggested_score,
s.name_ru AS suggested_name, s.specialty AS suggested_specialty
FROM price_item pi
JOIN partner p ON pi.partner_id = p.partner_id
LEFT JOIN service s ON pi.suggested_service_id = s.service_id
WHERE pi.service_id IS NULL AND pi.is_active = 1
AND pi.item_id NOT IN (SELECT item_id FROM skipped_item)
ORDER BY pi.suggested_score DESC, pi.item_id
LIMIT ? OFFSET ?"""
with db() as conn:
return [
{
"item_id": r["item_id"],
"service_name_raw": r["service_name_raw"],
"partner_name": r["partner_name"],
"unit": r["unit"],
"prices": json.loads(r["prices"] or "{}"),
"suggested": (
{
"service_id": r["suggested_service_id"],
"name_ru": r["suggested_name"],
"specialty": r["suggested_specialty"],
"score": r["suggested_score"],
}
if r["suggested_service_id"]
else None
),
}
for r in conn.execute(sql, [limit, offset])
]
@app.get("/unmatched/count", summary="Сколько позиций ещё в очереди верификации")
def unmatched_count():
with db() as conn:
n = conn.execute(
"""SELECT COUNT(*) FROM price_item
WHERE service_id IS NULL AND is_active = 1
AND item_id NOT IN (SELECT item_id FROM skipped_item)"""
).fetchone()[0]
learned = conn.execute("SELECT COUNT(*) FROM learned_synonym").fetchone()[0]
return {"remaining": n, "learned_synonyms": learned}
@app.post("/skip", summary="Отложить позицию (пропустить в очереди верификации)")
def skip_item(item_id: int):
with db() as conn:
conn.execute("INSERT OR IGNORE INTO skipped_item (item_id) VALUES (?)", [item_id])
conn.commit()
return {"item_id": item_id, "status": "skipped"}
_SUPPORTED_EXT = (".pdf", ".xlsx", ".xls", ".docx")
def _safe_member_name(member) -> str:
"""Имя файла из ZIP: чинит кириллицу (cp866) и срезает путь (защита от traversal)."""
name = member.filename
if not (member.flag_bits & 0x800): # нет UTF-8-флага — вероятно cp437/cp866 (рус. Windows)
try:
name = name.encode("cp437").decode("cp866")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
return os.path.basename(name.replace("\\", "/"))
@app.post("/ingest", summary="Загрузить ZIP-архив прайсов и обработать (догрузка в базу)")
def ingest_archive(file: UploadFile = File(...)):
"""Приём архива через интерфейс (ТЗ §4.1): распаковать ZIP и догрузить прайсы в базу.
Тяжёлые зависимости (конвейер, Gemini) импортируются лениво — read-only API живёт и
без них. Эмбеддинги — по возможности: при сбое Gemini обрабатываем без них (fuzzy),
архив всё равно принимается.
"""
import shutil
import tempfile
import zipfile
name = (file.filename or "").lower()
if not name.endswith(".zip"):
raise HTTPException(400, "ожидается ZIP-архив (.zip)")
workdir = Path(tempfile.mkdtemp(prefix="medarchive_ingest_"))
try:
zip_path = workdir / "upload.zip"
with zip_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
extract_dir = workdir / "files"
extract_dir.mkdir()
try:
with zipfile.ZipFile(zip_path) as zf:
members = [m for m in zf.infolist() if not m.is_dir()]
for member in members:
fname = _safe_member_name(member)
if not fname or not fname.lower().endswith(_SUPPORTED_EXT):
continue
with zf.open(member) as src, (extract_dir / fname).open("wb") as dst:
shutil.copyfileobj(src, dst)
except zipfile.BadZipFile as exc:
raise HTTPException(400, "файл не является корректным ZIP-архивом") from exc
prices = [p for p in extract_dir.iterdir() if p.is_file()]
if not prices:
raise HTTPException(
422, "в архиве не найдено прайсов поддерживаемых форматов (pdf, xlsx, xls, docx)"
)
from etl.pipeline import ingest # noqa: PLC0415 — ленивый импорт тяжёлых зависимостей
embedder = None
if os.environ.get("GEMINI_API_KEY"):
try:
from etl.normalize.embedding import GeminiEmbedder # noqa: PLC0415
embedder = GeminiEmbedder()
except Exception: # ключа/SDK нет — нормализуем без эмбеддингов
embedder = None
dict_path = str(Path(DB_PATH).parent / "reference/dictionary.xlsx")
kwargs = dict(data_dir=str(extract_dir), db_path=DB_PATH, dict_path=dict_path)
try:
summary = ingest(embedder=embedder, **kwargs)
degraded = False
except Exception:
# Сбой эмбеддингов (квота/сеть) не должен ронять приём архива — обрабатываем без них.
if embedder is None:
raise
summary = ingest(embedder=None, **kwargs)
degraded = True
return {
"status": "ok",
"files_accepted": len(prices),
"documents": summary["documents"],
"new_partners": summary["partners"],
"items": summary["items"],
"auto_matched": summary["auto_matched"],
"auto_matched_pct": summary["auto_matched_pct"],
"by_method": summary["by_method"],
"embeddings": not degraded and embedder is not None,
}
finally:
import shutil as _sh
_sh.rmtree(workdir, ignore_errors=True)
@app.post("/match", summary="Ручное сопоставление позиции (с дообучением)")
def manual_match(item_id: int, service_id: str):
with db() as conn:
if not conn.execute("SELECT 1 FROM service WHERE service_id = ?", [service_id]).fetchone():
raise HTTPException(404, "услуга справочника не найдена")
row = conn.execute(
"SELECT service_name_raw FROM price_item WHERE item_id = ?", [item_id]
).fetchone()
if not row:
raise HTTPException(404, "позиция не найдена")
conn.execute(
"UPDATE price_item SET service_id = ?, map_method = 'manual', map_confidence = 1.0 "
"WHERE item_id = ?",
[service_id, item_id],
)
# Дообучение: запоминаем синоним — следующие прогоны сопоставят его автоматически.
conn.execute(
"INSERT OR REPLACE INTO learned_synonym (name_norm, service_id) VALUES (?, ?)",
[_norm(row["service_name_raw"]), service_id],
)
conn.commit()
return {"item_id": item_id, "service_id": service_id, "status": "matched", "learned": True}
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
def stats():
"""Считается из базы — так после загрузки нового архива цифры обновляются сразу."""
with db() as conn:
docs = conn.execute("SELECT COUNT(*) FROM price_document").fetchone()[0]
items = conn.execute("SELECT COUNT(*) FROM price_item WHERE is_active = 1").fetchone()[0]
matched = conn.execute(
"SELECT COUNT(*) FROM price_item WHERE is_active = 1 AND service_id IS NOT NULL"
).fetchone()[0]
if items:
return Stats(
documents_total=docs,
documents_done=docs,
items_total=items,
auto_matched_pct=round(100 * matched / items, 1),
unmatched_total=items - matched,
)
# Фолбэк на отчёт сборки, если база ещё пуста.
report = Path(DB_PATH).parent / "quality_report.json"
if report.exists():
data = json.loads(report.read_text(encoding="utf-8"))
return Stats(
documents_total=data.get("documents", 0),
documents_done=data.get("documents", 0),
items_total=data.get("items", 0),
auto_matched_pct=data.get("auto_matched_pct", 0.0),
unmatched_total=data.get("unmatched", 0),
)
return Stats()
@app.get("/health", summary="Проверка живости")
def health():
return {"status": "ok", "db": Path(DB_PATH).exists()}
+28
View File
@@ -0,0 +1,28 @@
# Дизайн-заготовка (фирменная палитра)
Готовые токены для фронта (агент D). Полная дизайн-система: `Base/_Inbox/Хакатон Medtech/Дизайн-система`.
## Применение
1. `tailwind.tokens.ts` → импортировать в `tailwind.config.ts`, разложить в `theme.extend`.
2. `globals.css` → переменные в `:root` (shadcn/ui).
3. Шрифт **Geologica**`next/font` или `<link>` на fonts.bunny.net (веса 300700).
## Шпаргалка
| | |
|---|---|
| Primary (CTA) | `#ff4713`, текст белый, `rounded-lg`, hover `#e63e0f` |
| Вторичный | `#6b788e` (t-gray) |
| Фон страницы / карточек | `#f3f4f6` / белый `rounded-2xl shadow-card` |
| Успех / ошибка | `#0f973d` / `#dc2626` (фон `#fbe9e9`) |
| Шрифт | Geologica (заголовки 600700, текст 400) |
| Контейнер | `max-w-container` (1280px) |
## Ключевой компонент — таблица сравнения цен
- Шапка `bg-light-gray`, текст `t-gray` 600/14px.
- Строки белые, разделители `#ebedf0`, hover `bg-brand-soft`, цены вправо `tabular-nums`.
- Лучшая/нормализованная цена — бейдж `bg-brand text-white rounded-full`.
- Статусы нормализации: success `#0f973d` · конфликт `#dc2626` · «проверить» `#ff4713`.
- shadcn: `Table` + `Badge` (variants default=оранжевый / success / destructive) + TanStack `DataTable`.
+26
View File
@@ -0,0 +1,26 @@
/* shadcn/ui тема (фирменная палитра) — вставить в app/globals.css (:root)
Подключить шрифт Geologica: next/font или
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap"> */
:root {
--radius: 0.5rem; /* база кнопок/инпутов */
--background: 220 14% 96%; /* #f3f4f6 фон страницы */
--foreground: 222 47% 11%; /* #111827 текст */
--card: 0 0% 100%; /* белые карточки */
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 11 100% 54%; /* #ff4713 бренд-оранжевый */
--primary-foreground: 0 0% 100%;
--secondary: 218 15% 49%; /* #6b788e t-gray */
--secondary-foreground: 0 0% 100%;
--muted: 210 20% 97%; /* #f5f7f9 light-gray */
--muted-foreground: 220 9% 46%; /* #6b7280 */
--accent: 16 100% 95%; /* #ffede7 brand-soft */
--accent-foreground: 11 100% 45%;
--destructive: 0 72% 51%; /* #dc2626 */
--destructive-foreground: 0 0% 100%;
--success: 142 82% 32%; /* #0f973d */
--border: 220 13% 91%; /* #ebedf0 */
--input: 220 13% 85%; /* #d1d5db */
--ring: 11 100% 54%; /* оранжевое кольцо фокуса */
}
+18
View File
@@ -0,0 +1,18 @@
// Дизайн-токены (фирменная палитра) — вставить в tailwind.config.ts → theme.extend
export const brandTheme = {
colors: {
brand: { DEFAULT: "#ff4713", soft: "#ffede7", dark: "#e63e0f" }, // бренд-оранжевый
"t-gray": "#6b788e", // сине-серый (вторичный)
"light-gray": "#f5f7f9", // фон секций/карточек
link: "#2563eb",
success: { DEFAULT: "#0f973d", soft: "#e7f6ec" },
error: { DEFAULT: "#dc2626", soft: "#fbe9e9" },
ink: "#111827", // основной текст
},
fontFamily: {
sans: ["Geologica", "ui-sans-serif", "system-ui", "sans-serif"],
},
borderRadius: { lg: "0.5rem", xl: "0.75rem", "2xl": "1rem", "3xl": "1.5rem" },
maxWidth: { container: "80rem" }, // 1280px — контейнер
boxShadow: { card: "0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1)" },
};
+264
View File
@@ -0,0 +1,264 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MedArchive — сравнение цен на медуслуги</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { theme: { extend: {
colors: { brand: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
tg: '#6b788e', lg: '#f5f7f9', ink: '#111827' },
fontFamily: { sans: ['Geologica', 'ui-sans-serif', 'system-ui', 'sans-serif'] } } } };
</script>
<style>body{font-family:'Geologica',system-ui,sans-serif}</style>
</head>
<body class="bg-[#f3f4f6] text-ink min-h-screen">
<div class="h-1 bg-brand w-full"></div>
<header class="max-w-5xl mx-auto px-5 pt-8 pb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-2xl font-bold">Med<span class="text-brand">Archive</span></span>
<span class="text-xs text-tg bg-lg border border-[#ebedf0] rounded-full px-2 py-0.5">прайсы клиник-партнёров</span>
</div>
<nav class="text-sm text-tg flex gap-4">
<button onclick="show('search')" class="hover:text-ink">Поиск</button>
<button onclick="show('admin')" class="hover:text-ink">Админка</button>
</nav>
</header>
<main class="max-w-5xl mx-auto px-5 pb-16">
<!-- Поиск + сравнение -->
<section id="view-search">
<h1 class="text-3xl sm:text-4xl font-bold mt-6 leading-tight">Найдите услугу — <span class="text-brand">сравните цены</span> в клиниках</h1>
<p class="text-tg mt-2">Единый каталог услуг и цен из архива прайсов клиник-партнёров.</p>
<div class="mt-5 flex gap-2">
<input id="q" type="text" placeholder="например: УЗИ брюшной полости, приём кардиолога, общий анализ крови"
class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-4 py-3 outline-none focus:border-brand focus:ring-2 focus:ring-brand/40"
oninput="debouncedSearch()" autocomplete="off">
</div>
<div id="suggest" class="mt-2 bg-white border border-[#ebedf0] rounded-xl divide-y divide-[#f0f0f0] hidden shadow-sm"></div>
<div id="compare" class="mt-6"></div>
</section>
<!-- Админка + верификация -->
<section id="view-admin" class="hidden">
<h2 class="text-2xl font-bold mt-6">Дашборд обработки</h2>
<div id="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"></div>
<!-- Загрузка нового архива (ТЗ §4.1) -->
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5 mt-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 class="text-lg font-semibold">Загрузить архив прайсов</h3>
<p class="text-sm text-tg mt-1">ZIP с прайсами клиник (pdf, xlsx, xls, docx). Система распакует, распознает и догрузит позиции в базу.</p>
</div>
<div class="shrink-0">
<input id="zip" type="file" accept=".zip" class="hidden" onchange="uploadArchive(this)">
<button onclick="$('zip').click()" class="bg-brand text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-brand-dark">Выбрать ZIP</button>
</div>
</div>
<div id="ingest-result" class="mt-3 text-sm hidden"></div>
</div>
<div class="flex items-center justify-between mt-8">
<h3 class="text-lg font-semibold">Очередь верификации</h3>
<div id="vprogress" class="text-sm text-tg"></div>
</div>
<p class="text-sm text-tg mt-1">Система предлагает кандидата из справочника — подтвердите, выберите другой или пропустите. Подтверждение обучает систему.</p>
<div id="queue" class="mt-3 space-y-2"></div>
<button id="loadmore" onclick="loadQueue()" class="mt-3 text-sm text-brand hover:underline hidden">Загрузить ещё →</button>
</section>
</main>
<script>
const API = new URLSearchParams(location.search).get('api') || '/api';
const $ = (id) => document.getElementById(id);
const money = (v) => v == null ? '—' : new Intl.NumberFormat('ru-RU').format(v) + ' ₸';
function show(view) {
$('view-search').classList.toggle('hidden', view !== 'search');
$('view-admin').classList.toggle('hidden', view !== 'admin');
if (view === 'admin') loadAdmin();
}
let timer;
function debouncedSearch() { clearTimeout(timer); timer = setTimeout(search, 250); }
async function search() {
const q = $('q').value.trim();
const box = $('suggest');
if (q.length < 2) { box.classList.add('hidden'); return; }
const data = await fetch(`${API}/search?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
const items = (data.services || []);
box.innerHTML = items.length
? items.map(s => `<button class="block w-full text-left px-4 py-2.5 hover:bg-brand-soft"
onclick="compare('${s.service_id}', ${JSON.stringify(s.name_ru).replace(/"/g, '&quot;')})">
<span class="font-medium">${s.name_ru}</span>
<span class="text-xs text-tg ml-2">${s.specialty || ''}</span></button>`).join('')
: `<div class="px-4 py-3 text-tg text-sm">Ничего не найдено</div>`;
box.classList.remove('hidden');
}
async function compare(serviceId, name) {
$('suggest').classList.add('hidden');
const rows = await fetch(`${API}/services/${encodeURIComponent(serviceId)}/partners`).then(r => r.json());
const vals = rows.map(r => r.price_resident_kzt).filter(v => v != null).sort((a, b) => a - b);
const best = vals.length ? vals[0] : null;
const worst = vals.length ? vals[vals.length - 1] : null;
const median = vals.length ? (vals.length % 2 ? vals[(vals.length - 1) / 2] : (vals[vals.length / 2 - 1] + vals[vals.length / 2]) / 2) : null;
const dev = (v) => (median && v != null) ? Math.round((v - median) / median * 100) : null;
const spread = (best != null && worst != null) ? worst - best : null;
$('compare').innerHTML = `
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm overflow-hidden">
<div class="px-5 py-4 border-b border-[#ebedf0]">
<div class="text-lg font-semibold">${name}</div>
<div class="text-sm text-tg">${rows.length} клиник(и) · медиана ${money(median)}${spread ? ' · разброс цен до <b class="text-brand">' + money(spread) + '</b>' : ''}</div>
</div>
<table class="w-full text-sm">
<thead class="bg-lg text-tg text-left">
<tr><th class="px-5 py-2.5 font-semibold">Клиника</th>
<th class="px-5 py-2.5 font-semibold text-right">Резидент</th>
<th class="px-5 py-2.5 font-semibold text-right">vs медиана</th>
<th class="px-5 py-2.5 font-semibold text-right">Нерезидент</th>
<th class="px-5 py-2.5 font-semibold text-right">Прайс от</th></tr>
</thead>
<tbody class="divide-y divide-[#f0f0f0]">
${rows.map(r => {
const cheapest = r.price_resident_kzt != null && r.price_resident_kzt === best;
const d = dev(r.price_resident_kzt);
const badge = d == null ? '—' : (d > 0
? `<span class="text-[#dc2626] font-medium">+${d}%</span>`
: `<span class="text-[#0f973d] font-medium">${d}%</span>`);
return `<tr class="${cheapest ? 'bg-brand-soft' : ''}">
<td class="px-5 py-3">${r.partner_name}${cheapest ? ' <span class="text-brand text-xs font-semibold">· выгодно</span>' : ''}</td>
<td class="px-5 py-3 text-right tabular-nums font-medium">${money(r.price_resident_kzt)}</td>
<td class="px-5 py-3 text-right tabular-nums text-xs">${badge}</td>
<td class="px-5 py-3 text-right tabular-nums text-tg">${money(r.price_nonresident_kzt)}</td>
<td class="px-5 py-3 text-right text-tg text-xs">${r.effective_date || '—'}</td></tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
async function loadAdmin() {
const s = await fetch(`${API}/stats`).then(r => r.json());
const cards = [
['Документов', s.documents_total], ['Позиций', s.items_total],
['Автонормализация', s.auto_matched_pct + '%'], ['В очереди', s.unmatched_total]];
$('stats').innerHTML = cards.map(([l, v]) => `
<div class="bg-white border border-[#ebedf0] rounded-xl p-4">
<div class="text-2xl font-bold text-brand">${v}</div>
<div class="text-xs text-tg mt-1">${l}</div></div>`).join('');
loadQueue();
}
async function uploadArchive(input) {
const f = input.files[0];
if (!f) return;
const box = $('ingest-result');
box.classList.remove('hidden');
box.innerHTML = `<span class="text-tg">⏳ Обрабатываю <b>${f.name}</b> — распаковка, распознавание и нормализация. Может занять до минуты…</span>`;
const fd = new FormData();
fd.append('file', f);
try {
const res = await fetch(`${API}/ingest`, { method: 'POST', body: fd });
const r = await res.json().catch(() => ({}));
if (!res.ok) {
box.innerHTML = `<span class="text-[#dc2626]">Ошибка: ${r.detail || res.status}</span>`;
} else {
const note = r.embeddings ? '' : ' <span class="text-tg">(нормализация по кодам и нечёткому совпадению — без эмбеддингов)</span>';
box.innerHTML = `<div class="bg-brand-soft border border-[#ffd9cc] rounded-lg p-3">
✓ Архив обработан: <b>${r.documents}</b> прайс(ов), <b>${r.items}</b> позиций,
автонормализация <b class="text-brand">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
<div class="text-tg mt-1">Новые позиции уже доступны в поиске и в очереди верификации.</div></div>`;
loadAdmin(); // обновить дашборд и очередь под новые данные
}
} catch (e) {
box.innerHTML = `<span class="text-[#dc2626]">Сбой загрузки: ${e}</span>`;
}
input.value = '';
}
async function loadQueue() {
const items = await fetch(`${API}/unmatched?limit=25`).then(r => r.json());
$('queue').innerHTML = items.map(renderCard).join('') || '<div class="text-tg p-4">Очередь пуста 🎉</div>';
$('loadmore').classList.toggle('hidden', items.length === 0);
loadCounts();
}
function maybeRefill() {
// Рабочая очередь: когда необработанных карточек мало — подгружаем следующую порцию.
if ($('queue').querySelectorAll('[id^="card-"]').length < 6) loadQueue();
}
async function loadCounts() {
const c = await fetch(`${API}/unmatched/count`).then(r => r.json());
$('vprogress').innerHTML = `осталось <b>${c.remaining}</b> · обучено синонимов <b class="text-brand">${c.learned_synonyms}</b>`;
}
function renderCard(it) {
const price = Object.values(it.prices)[0];
const sug = it.suggested;
const sugHtml = sug
? `<div class="text-sm mt-1">предложение: <b>${sug.name_ru}</b> <span class="text-tg">${sug.specialty || ''} · ${(sug.score || 0).toFixed(2)}</span></div>`
: `<div class="text-sm text-tg mt-1">кандидата нет</div>`;
const confirm = sug
? `<button onclick="doMatch(${it.item_id}, '${sug.service_id}', this)" class="bg-brand text-white text-sm rounded-lg px-3 py-1.5 hover:bg-brand-dark">✓ Подтвердить</button>`
: '';
return `<div id="card-${it.item_id}" class="bg-white border border-[#ebedf0] rounded-xl p-4">
<div class="flex justify-between items-start gap-3">
<div>
<div>извлечено: <b>${it.service_name_raw}</b></div>
<div class="text-sm text-tg">${it.partner_name}${price != null ? ' · ' + money(price) : ''}</div>
${sugHtml}
</div>
<div class="flex gap-2 shrink-0">
${confirm}
<button onclick="togglePick(${it.item_id})" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Другое</button>
<button onclick="skipCard(${it.item_id})" class="text-tg text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Пропустить</button>
</div>
</div>
<div id="pick-${it.item_id}" class="hidden mt-3">
<input oninput="searchDict(${it.item_id}, this.value)" placeholder="найти услугу в справочнике…"
class="w-full bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-brand">
<div id="dict-${it.item_id}" class="mt-1 bg-white border border-[#ebedf0] rounded-lg divide-y divide-[#f0f0f0] max-h-44 overflow-auto"></div>
</div>
</div>`;
}
async function doMatch(itemId, serviceId, btn) {
if (btn) btn.disabled = true;
await fetch(`${API}/match?item_id=${itemId}&service_id=${encodeURIComponent(serviceId)}`, { method: 'POST' });
const card = $(`card-${itemId}`); if (card) card.remove();
loadCounts(); maybeRefill();
}
function togglePick(id) { $(`pick-${id}`).classList.toggle('hidden'); }
async function skipCard(id) {
await fetch(`${API}/skip?item_id=${id}`, { method: 'POST' });
const c = $(`card-${id}`); if (c) c.remove();
loadCounts(); maybeRefill();
}
let dictTimer;
function searchDict(itemId, q) {
clearTimeout(dictTimer);
dictTimer = setTimeout(async () => {
if (q.trim().length < 2) return;
const items = await fetch(`${API}/services?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
$(`dict-${itemId}`).innerHTML = items.map(s =>
`<button class="block w-full text-left px-3 py-2 text-sm hover:bg-brand-soft" onclick="doMatch(${itemId}, '${s.service_id}', null)">${s.name_ru} <span class="text-tg text-xs">${s.specialty || ''}</span></button>`).join('');
}, 250);
}
</script>
</body>
</html>