feat: API и веб-интерфейс — поиск, сравнение цен, экран верификации, загрузка архива
This commit is contained in:
+375
@@ -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()}
|
||||
@@ -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 (веса 300–700).
|
||||
|
||||
## Шпаргалка
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Primary (CTA) | `#ff4713`, текст белый, `rounded-lg`, hover `#e63e0f` |
|
||||
| Вторичный | `#6b788e` (t-gray) |
|
||||
| Фон страницы / карточек | `#f3f4f6` / белый `rounded-2xl shadow-card` |
|
||||
| Успех / ошибка | `#0f973d` / `#dc2626` (фон `#fbe9e9`) |
|
||||
| Шрифт | Geologica (заголовки 600–700, текст 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`.
|
||||
@@ -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%; /* оранжевое кольцо фокуса */
|
||||
}
|
||||
@@ -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
@@ -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, '"')})">
|
||||
<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>
|
||||
Reference in New Issue
Block a user