51df0aa490
- POST /scan: сервис забирает новые прайсы из папки (настройка watch_folder), журнал ingested_file исключает повторную обработку; вызывается кроном. - GET /price-changes: услуги, где цена изменилась не меньше порога между прайсами (детектор аномалий из ТЗ §4.4, вынесенный в интерфейс). - GET /export.xlsx: единый сравнительный каталог услуг и цен (openpyxl). - GET/POST /settings: папка авто-загрузки. - админка: карточки авто-загрузки и изменений цен + кнопка экспорта. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
335 lines
18 KiB
HTML
335 lines
18 KiB
HTML
<!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', ok: '#0f973d' },
|
||
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">
|
||
<div class="flex items-center justify-between mt-6 gap-4 flex-wrap">
|
||
<h2 class="text-2xl font-bold">Дашборд обработки</h2>
|
||
<button onclick="exportXlsx()" class="bg-white border border-[#d1d5db] text-sm rounded-lg px-4 py-2 font-medium hover:bg-lg">📥 Экспорт в Excel</button>
|
||
</div>
|
||
<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="grid md:grid-cols-2 gap-4 mt-4">
|
||
<!-- Авто-загрузка из папки (watcher + крон) -->
|
||
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5">
|
||
<h3 class="text-lg font-semibold">Авто-загрузка из папки</h3>
|
||
<p class="text-sm text-tg mt-1">Укажите папку — система в кроне сама заберёт и обработает свежие прайсы, без участия человека.</p>
|
||
<div class="flex gap-2 mt-3">
|
||
<input id="watchfolder" placeholder="например: /app/inbox" class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-brand">
|
||
<button onclick="saveFolder()" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-2 hover:bg-lg shrink-0">Сохранить</button>
|
||
</div>
|
||
<button onclick="scanNow()" class="bg-brand text-white text-sm rounded-lg px-4 py-2 mt-2 font-medium hover:bg-brand-dark">Проверить сейчас</button>
|
||
<div id="scan-result" class="text-sm mt-2"></div>
|
||
</div>
|
||
<!-- Изменения цен (детектор аномалий, ТЗ §4.4) -->
|
||
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-lg font-semibold">Изменения цен</h3>
|
||
<span id="pc-count" class="text-xs text-tg"></span>
|
||
</div>
|
||
<p class="text-sm text-tg mt-1">Услуги, где новый прайс изменил цену на ≥ 10% относительно прошлого.</p>
|
||
<div id="price-changes" class="mt-3 space-y-1 max-h-64 overflow-auto"></div>
|
||
</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();
|
||
loadSettings();
|
||
loadPriceChanges();
|
||
}
|
||
|
||
function exportXlsx() { window.location = `${API}/export.xlsx`; }
|
||
|
||
async function loadSettings() {
|
||
const s = await fetch(`${API}/settings`).then(r => r.json());
|
||
$('watchfolder').value = s.watch_folder || '';
|
||
}
|
||
|
||
async function saveFolder() {
|
||
const folder = $('watchfolder').value.trim();
|
||
await fetch(`${API}/settings?watch_folder=${encodeURIComponent(folder)}`, { method: 'POST' });
|
||
$('scan-result').innerHTML = '<span class="text-tg">Папка сохранена.</span>';
|
||
}
|
||
|
||
async function scanNow() {
|
||
const box = $('scan-result');
|
||
box.innerHTML = '<span class="text-tg">⏳ Проверяю папку…</span>';
|
||
const res = await fetch(`${API}/scan`, { method: 'POST' });
|
||
const r = await res.json().catch(() => ({}));
|
||
if (!res.ok) { box.innerHTML = `<span class="text-[#dc2626]">${r.detail || 'ошибка'}</span>`; return; }
|
||
if (!r.new_files) { box.innerHTML = '<span class="text-tg">Новых прайсов нет.</span>'; return; }
|
||
box.innerHTML = `<span class="text-ok">✓ Обработано файлов: <b>${r.new_files}</b>, позиций: <b>${r.items}</b>, нормализовано <b>${r.auto_matched_pct}%</b>.</span>`;
|
||
loadAdmin();
|
||
}
|
||
|
||
async function loadPriceChanges() {
|
||
const d = await fetch(`${API}/price-changes?threshold=10&limit=40`).then(r => r.json());
|
||
$('pc-count').textContent = d.count ? `${d.count} услуг` : '';
|
||
if (!d.changes || !d.changes.length) {
|
||
$('price-changes').innerHTML = '<div class="text-tg text-sm">Изменений не найдено.</div>';
|
||
return;
|
||
}
|
||
$('price-changes').innerHTML = d.changes.map(c => {
|
||
const up = c.delta_pct > 0;
|
||
const badge = `<span class="${up ? 'text-[#dc2626]' : 'text-ok'} font-semibold whitespace-nowrap">${up ? '▲ +' : '▼ '}${c.delta_pct}%</span>`;
|
||
return `<div class="flex items-center justify-between gap-3 border-b border-[#f0f0f0] py-1.5">
|
||
<div class="min-w-0">
|
||
<div class="text-sm truncate">${c.service}</div>
|
||
<div class="text-xs text-tg">${c.partner_name} · ${money(c.old_price)} → ${money(c.new_price)}</div>
|
||
</div>${badge}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
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>
|