feat: product search + potato/onion/carrot/beet in demo + demo-data banner + /api/products
- overview: search field with datalist -> /product/<id> (matches ru label or id)
- demo_data: +4 tubers (potato KZ/TJ/UZ, onion, carrot, beet)
- product page: banner 'Демо-данные' when all sources are demo_*
- /api/products: id->{label} for search
- regenerated demo + report (10 sheets incl tubers_potato)
This commit is contained in:
parent
c6705c1cd9
commit
0900a06bd0
Binary file not shown.
@ -310,11 +310,20 @@ def product_page(product_id: str, days: int = Query(30, ge=7, le=180)) -> HTMLRe
|
||||
f"<b>{html.escape(r['source_id'])}</b> · <span class='muted'>{html.escape(r['region'])}</span> "
|
||||
f"<span class='muted' style='font-size:12px'>{r['first_d']} → {r['last_d']} · {r['n']} записей</span></li>"
|
||||
for r in src_rows) or "<li class='muted'>источники ещё не собрали этот товар</li>"
|
||||
# Banner: if ALL sources for this product are 'demo_*', it's synthetic data
|
||||
all_demo = len(src_rows) > 0 and all(r["source_id"].startswith("demo_") for r in src_rows)
|
||||
demo_banner = (
|
||||
"<p class='muted' style='font-size:12px;margin-top:10px'>"
|
||||
"Демо-данные (источник demo_*). Подключите реальный источник на "
|
||||
"<a class='kt-ai-link' href='/sources'>странице «Источники»</a>, чтобы увидеть живые цены."
|
||||
"</p>" if all_demo else ""
|
||||
)
|
||||
body = _render("product",
|
||||
PRODUCT=html.escape(product_id),
|
||||
PRODUCT_LABEL=html.escape(product_id.replace("_", " ")),
|
||||
LATEST_VAL=f"{val:,.1f}" if val else "—",
|
||||
LATEST_AS_OF=as_of or "—",
|
||||
PRODUCT=html.escape(product_id),
|
||||
PRODUCT_LABEL=html.escape(product_id.replace("_", " ")),
|
||||
DEMO_BANNER=demo_banner,
|
||||
LATEST_VAL=f"{val:,.1f}" if val else "—",
|
||||
LATEST_AS_OF=as_of or "—",
|
||||
LATEST_REGION=html.escape(region or "—"),
|
||||
LATEST_SRC_URL=html.escape(src_url or ""),
|
||||
LATEST_FRAGMENT=html.escape(frag[:120] if frag else "—"),
|
||||
@ -534,6 +543,33 @@ def api_arbitrage(product: str, from_: str = "KZ", to: str = "TJ",
|
||||
return a
|
||||
|
||||
|
||||
@app.get("/api/products")
|
||||
def api_products() -> dict:
|
||||
"""id → best label, for the overview search field.
|
||||
Uses category/subcategory as the human label (ru from taxonomy)."""
|
||||
import yaml
|
||||
try:
|
||||
with open(config.TAXONOMY_PATH, "r", encoding="utf-8") as f:
|
||||
tax = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
tax = {}
|
||||
sub_label = {}
|
||||
for c in tax.get("categories", []):
|
||||
for s in c.get("subcategories", []):
|
||||
sub_label[(c.get("category") or c.get("id"), s.get("id"))] = s.get("label_ru") or s.get("label") or s.get("id")
|
||||
cat_label = {c.get("category") or c.get("id"): c.get("label_ru") or c.get("label") or (c.get("category") or c.get("id"))
|
||||
for c in tax.get("categories", [])}
|
||||
out = {}
|
||||
for r in db.all_products():
|
||||
cat = r.get("category") or ""
|
||||
sub = r.get("subcategory") or ""
|
||||
pid = r["product"]
|
||||
# best label: prefer sub label, fall back to sub id readable
|
||||
lbl = sub_label.get((cat, sub), (sub.replace("_", " ").capitalize() if sub else pid))
|
||||
out[pid] = lbl
|
||||
return {"products": out}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict:
|
||||
return {"ok": True, "time": date.today().isoformat()}
|
||||
|
||||
@ -1,8 +1,62 @@
|
||||
<section class="kt-ai-hero" data-align="left" style="padding-top:24px;padding-bottom:16px">
|
||||
<section class="kt-ai-hero" data-align="left" style="padding-top:24px;padding-bottom:12px">
|
||||
<h1 style="font-size:32px;margin:0">Обзор рынка</h1>
|
||||
<p class="kt-ai-hero-sub">Демо: {{ TODAY }}. Все цены в тг/кг. Карантин = данные, прошедшие валидацию и отправленные на проверку.</p>
|
||||
<p class="kt-ai-hero-sub">Демо-набор данных от {{ TODAY }}. Все цены в тг/кг. «Карантин» — записи, отправленные на проверку.</p>
|
||||
</section>
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<form onsubmit="return gotoProduct(event)">
|
||||
<input id="prod-search" type="text" list="prod-list" placeholder="Найти товар: картофель, пшеница, молоко…"
|
||||
style="width:100%;height:38px;padding:0 12px;border-radius:8px;border:1px solid var(--kt-ai-border);background:var(--kt-ai-surface);font-size:15px">
|
||||
</form>
|
||||
<datalist id="prod-list"></datalist>
|
||||
<p class="muted" style="font-size:12px;margin:8px 0 0">
|
||||
Демо-данные (сгенерированы для примера). Чтобы увидеть живые цены, подключите реальный источник
|
||||
на странице <a class="kt-ai-link" href="/sources">Источники</a> или загрузите CSV/HTML оттуда же.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
fetch('api/products').then(r => r.json()).then(j => {
|
||||
const prods = j && j.products || {};
|
||||
const dl = document.getElementById('prod-list');
|
||||
Object.keys(prods).forEach(p => {
|
||||
const o = document.createElement('option'); o.value = prods[p]; dl.appendChild(o);
|
||||
});
|
||||
window.__products = prods;
|
||||
}).catch(() => {});
|
||||
})();
|
||||
function gotoProduct(e) {
|
||||
e && e.preventDefault();
|
||||
const q = (document.getElementById('prod-search').value || '').trim().toLowerCase();
|
||||
if (!window.__products || !q) { location.href = '/products'; return true; }
|
||||
const ids = Object.keys(window.__products).filter(p =>
|
||||
p === q || p.replace(/_/g, ' ').includes(q) ||
|
||||
window.__products[p].toLowerCase().includes(q));
|
||||
if (ids.length === 1) { location.href = '/product/' + encodeURIComponent(ids[0]); return true; }
|
||||
if (ids.length > 1) {
|
||||
// show a quick disambiguation
|
||||
const wrap = document.getElementById('prod-search');
|
||||
if (!window.__pick) {
|
||||
window.__pick = document.createElement('div');
|
||||
document.body.appendChild(window.__pick);
|
||||
}
|
||||
window.__pick.style.cssText = 'position:fixed;top:120px;left:50%;transform:translateX(-50%);z-index:99;' +
|
||||
'background:var(--kt-ai-surface);border:1px solid var(--kt-ai-border);border-radius:10px;padding:12px 14px;box-shadow:0 6px 24px rgba(0,0,0,.12)';
|
||||
window.__pick.innerHTML = '<b style="font-size:13px;color:var(--kt-ai-muted)">Выберите товар:</b>' +
|
||||
'<ul style="list-style:none;padding:0;margin:8px 0 0;text-align:left;max-width:220px">' +
|
||||
ids.slice(0, 8).map(p =>
|
||||
'<li style="padding:5px 0"><a class="kt-ai-link" href="/product/' + encodeURIComponent(p) + '">' +
|
||||
window.__products[p] + ' <span class="muted" style="font-size:11px">(' + p + ')</span></a></li>'
|
||||
).join('') + '</ul>';
|
||||
setTimeout(() => { window.__pick && (window.__pick.style.display = 'none'); }, 4000);
|
||||
return true;
|
||||
}
|
||||
location.href = '/products';
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kpi-row">
|
||||
<div class="kpi"><div class="num">{{ KPI_PRODUCTS }}</div><div class="lbl">товаров за 7 дн.</div></div>
|
||||
<div class="kpi"><div class="num">{{ KPI_COUNTRIES }}</div><div class="lbl">стран</div></div>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
<span class="chip">фрагмент: {{ LATEST_FRAGMENT }}</span>
|
||||
</p>
|
||||
<div style="margin-top:8px">{{ COUNTRY_CHIPS }}</div>
|
||||
{{ DEMO_BANNER }}
|
||||
</section>
|
||||
|
||||
<div class="panel">
|
||||
|
||||
@ -53,6 +53,23 @@ DEMO_SOURCES = [
|
||||
"meat_mutton", "meat", "mutton", "wholesale"),
|
||||
("demo_tj", "TJ", "TJ-Dushanbe", "Dushanbe wholesale", "https://agromarket.asia/demo/dushanbe", 265.0,
|
||||
"meat_mutton", "meat", "mutton", "wholesale"),
|
||||
# --- Tubers / корнеплоды ---
|
||||
("demo_kz", "KZ", "KZ-Sharyn", "Sharyn", "https://agromarket.asia/demo/sharyn", 55.0,
|
||||
"tubers_potato", "tubers", "potato", "wholesale"),
|
||||
("demo_uz", "UZ", "UZ-Tashkent", "Tashkent wholesale", "https://agromarket.asia/demo/tashkent", 72.0,
|
||||
"tubers_potato", "tubers", "potato", "wholesale"),
|
||||
("demo_tj", "TJ", "TJ-Dushanbe", "Dushanbe wholesale", "https://agromarket.asia/demo/dushanbe", 90.0,
|
||||
"tubers_potato", "tubers", "potato", "wholesale"),
|
||||
("demo_kz", "KZ", "KZ-Sharyn", "Sharyn", "https://agromarket.asia/demo/sharyn", 210.0,
|
||||
"tubers_onion", "tubers", "onion", "wholesale"),
|
||||
("demo_uz", "UZ", "UZ-Tashkent", "Tashkent wholesale", "https://agromarket.asia/demo/tashkent", 180.0,
|
||||
"tubers_onion", "tubers", "onion", "wholesale"),
|
||||
("demo_kz", "KZ", "KZ-Sharyn", "Sharyn", "https://agromarket.asia/demo/sharyn", 160.0,
|
||||
"tubers_carrot", "tubers", "carrot", "wholesale"),
|
||||
("demo_uz", "UZ", "UZ-Tashkent", "Tashkent wholesale", "https://agromarket.asia/demo/tashkent", 140.0,
|
||||
"tubers_carrot", "tubers", "carrot", "wholesale"),
|
||||
("demo_kz", "KZ", "KZ-Sharyn", "Sharyn", "https://agromarket.asia/demo/sharyn", 150.0,
|
||||
"tubers_beet", "tubers", "beet", "wholesale"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user