feat: авто-загрузка из папки, алерты об изменении цен, экспорт каталога в Excel

- 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>
This commit is contained in:
2026-06-27 13:13:38 +05:00
parent 195b8fbc5e
commit 51df0aa490
2 changed files with 278 additions and 27 deletions
+72 -2
View File
@@ -10,7 +10,7 @@
<script>
tailwind.config = { theme: { extend: {
colors: { brand: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
tg: '#6b788e', lg: '#f5f7f9', ink: '#111827' },
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>
@@ -48,7 +48,10 @@
<!-- Админка + верификация -->
<section id="view-admin" class="hidden">
<h2 class="text-2xl font-bold mt-6">Дашборд обработки</h2>
<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) -->
@@ -66,6 +69,29 @@
<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>
@@ -158,6 +184,50 @@ async function loadAdmin() {
<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) {