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"<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>"
|
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>"
|
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",
|
body = _render("product",
|
||||||
PRODUCT=html.escape(product_id),
|
PRODUCT=html.escape(product_id),
|
||||||
PRODUCT_LABEL=html.escape(product_id.replace("_", " ")),
|
PRODUCT_LABEL=html.escape(product_id.replace("_", " ")),
|
||||||
LATEST_VAL=f"{val:,.1f}" if val else "—",
|
DEMO_BANNER=demo_banner,
|
||||||
LATEST_AS_OF=as_of or "—",
|
LATEST_VAL=f"{val:,.1f}" if val else "—",
|
||||||
|
LATEST_AS_OF=as_of or "—",
|
||||||
LATEST_REGION=html.escape(region or "—"),
|
LATEST_REGION=html.escape(region or "—"),
|
||||||
LATEST_SRC_URL=html.escape(src_url or ""),
|
LATEST_SRC_URL=html.escape(src_url or ""),
|
||||||
LATEST_FRAGMENT=html.escape(frag[:120] if frag else "—"),
|
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
|
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")
|
@app.get("/healthz")
|
||||||
def healthz() -> dict:
|
def healthz() -> dict:
|
||||||
return {"ok": True, "time": date.today().isoformat()}
|
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>
|
<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>
|
</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-row">
|
||||||
<div class="kpi"><div class="num">{{ KPI_PRODUCTS }}</div><div class="lbl">товаров за 7 дн.</div></div>
|
<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>
|
<div class="kpi"><div class="num">{{ KPI_COUNTRIES }}</div><div class="lbl">стран</div></div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<span class="chip">фрагмент: {{ LATEST_FRAGMENT }}</span>
|
<span class="chip">фрагмент: {{ LATEST_FRAGMENT }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div style="margin-top:8px">{{ COUNTRY_CHIPS }}</div>
|
<div style="margin-top:8px">{{ COUNTRY_CHIPS }}</div>
|
||||||
|
{{ DEMO_BANNER }}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|||||||
@ -53,6 +53,23 @@ DEMO_SOURCES = [
|
|||||||
"meat_mutton", "meat", "mutton", "wholesale"),
|
"meat_mutton", "meat", "mutton", "wholesale"),
|
||||||
("demo_tj", "TJ", "TJ-Dushanbe", "Dushanbe wholesale", "https://agromarket.asia/demo/dushanbe", 265.0,
|
("demo_tj", "TJ", "TJ-Dushanbe", "Dushanbe wholesale", "https://agromarket.asia/demo/dushanbe", 265.0,
|
||||||
"meat_mutton", "meat", "mutton", "wholesale"),
|
"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