From c6705c1cd9167443aef8cebbda561c85851edee8 Mon Sep 17 00:00:00 2001 From: Elshat Date: Thu, 24 Sep 2026 07:15:28 +0000 Subject: [PATCH] feat: Excel export (in-browser SheetJS on /countries + CLI makexlsx report) + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - countries.html: 'Скачать в Excel' button (SheetJS CDN) - base.html: xlsx.full.min.js from CDNSheetJS - src/export_excel.py: CLI → data/agromarket_report.xlsx (dynamics per product + spreads + alerts) - README: upload + export docs --- README.md | 8 ++ data/agromarket_report.xlsx | Bin 0 -> 13892 bytes src/dashboard/templates/base.html | 1 + src/dashboard/templates/countries.html | 19 +++- src/export_excel.py | 149 +++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 data/agromarket_report.xlsx create mode 100644 src/export_excel.py diff --git a/README.md b/README.md index 1e245e2..fc4c7e6 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,17 @@ python3 -m src.dashboard.run # дашборд на $PORT (дефолт python3 -m src.scheduler --once # один прогон пайплайна + дайджест python3 -m src.scheduler --daemon # демон (ежедневно 07:30) python3 -m src.demo_data # синтетические данные (60 дней) +python3 -m src.export_excel # отчёт в data/agromarket_report.xlsx python3 -m pytest src/tests -q # тесты ``` +Дополнительно: +- На странице **Источники** — форма ручной загрузки CSV/HTML (Arbuz.kz, Sharyn и аналоги без API). + Запись идёт через тот же пайплайн; низкая уверенность → очередь на проверку. +- На странице **Страны** — кнопка «Скачать в Excel» (SheetJS, выгрузка раскладок прямо из браузера). +- CLI-отчёт `python3 -m src.export_excel` кладёт `.xlsx` (динамика по товарам + раскладки + алерты) + в `data/` — скачивается из вкладки **Файлы**. + ## Структура - `src/pipeline/` — fetch, extract, normalize, categorize, validate, llm_extract, raw, orchestrator - `src/analytics/` — dynamics, spreads/arbitrage, seasonality, anomalies, alerts, forecast diff --git a/data/agromarket_report.xlsx b/data/agromarket_report.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7cd4e0bd9982461cf1c7be95cc1a9e0d8650305b GIT binary patch literal 13892 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 VfP?baHXl^LE%*-MX8U#Y{{dF_FhBqR literal 0 HcmV?d00001 diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index 3cac44a..4580a10 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -54,6 +54,7 @@ + {{ STYLE }} diff --git a/src/dashboard/templates/countries.html b/src/dashboard/templates/countries.html index 84cfbc4..969a2f2 100644 --- a/src/dashboard/templates/countries.html +++ b/src/dashboard/templates/countries.html @@ -4,8 +4,13 @@
-

Раскладки по товарам (за 30 дн.)

- +
+

Раскладки по товарам (за 30 дн.)

+ +
+
@@ -19,3 +24,13 @@ {{ ARB_HTML }} + + diff --git a/src/export_excel.py b/src/export_excel.py new file mode 100644 index 0000000..faafa5c --- /dev/null +++ b/src/export_excel.py @@ -0,0 +1,149 @@ +"""CLI export: dump analytics to an .xlsx the user can grab from the Files tab. + +Uses the host `makexlsx` command (no openpyxl in the run container). +Builds CSVs for each sheet, then shells out to makexlsx with a JSON of sheets. + +Usage: + python3 -m src.export_excel # latest 30d: dynamics + spreads + alerts + python3 -m src.export_excel --out data/report.xlsx + python3 -m src.export_excel --days 60 +""" +from __future__ import annotations +import argparse +import csv +import json +import os +import subprocess +import tempfile +from datetime import date, timedelta +from pathlib import Path + +from . import db + + +def _daily_series(product: str, days: int) -> list[dict]: + """Daily avg/min/max/aggregated rows for one product (mirrors dashboard series_for).""" + import statistics + to = date.today() + frm = to - timedelta(days=days) + rows = db.query_prices(product=product, from_date=frm, to_date=to, + include_quarantine=False, limit=10000) + agg: dict[str, list[float]] = {} + for r in rows: + if r.get("value_kg"): + agg.setdefault(r["as_of"], []).append(r["value_kg"]) + out = [] + for d in sorted(agg): + vals = agg[d] + out.append({"as_of": d, "n": len(vals), + "avg_val": round(statistics.mean(vals), 4), + "min_val": round(min(vals), 4), "max_val": round(max(vals), 4), + "med_val": round(statistics.median(vals), 4)}) + return out + + +def _sheet_dynamics(product: str, days: int) -> list[list[str]]: + """Daily series for one product: date, mean, min, max, n, delta%.""" + series = _daily_series(product, days) + rows = [["Дата", "Среднее, тг/кг", "Мин", "Макс", "Записей", "Δ% к пред. дню"]] + prev = None + for s in series: + avg = s.get("avg_val") + pct = "" if prev is None or not avg else round((avg - prev) / prev * 100, 2) + rows.append([s["as_of"], round(avg or 0, 2), + round(s.get("min_val") or 0, 2), round(s.get("max_val") or 0, 2), + s.get("n", 0), pct]) + if avg is not None: + prev = avg + return rows + + +def _sheet_spreads(days: int) -> list[list[str]]: + today = date.today() + rows = db.query_prices(from_date=today - timedelta(days=days), to_date=today, + include_quarantine=False, limit=20000) + by_prod: dict[str, dict[str, list[dict]]] = {} + for r in rows: + by_prod.setdefault(r["product"], {}).setdefault(r["country"], []).append(r) + out = [["Товар", "Откуда", "Куда", "A, тг/кг", "B, тг/кг", "Δ, тг/кг", "Δ%", "Даты"]] + for p, cd in by_prod.items(): + if len(cd) < 2: + continue + last_v: dict[str, tuple[float, str]] = {} + for c, rs in cd.items(): + agg: dict[str, list[float]] = {} + for r in rs: + agg.setdefault(r["as_of"], []).append(r["value_kg"]) + if not agg: + continue + last = max(agg) + last_v[c] = (sum(agg[last]) / len(agg[last]), last) + items = list(last_v.items()) # [(country, (val, date_str)), ...] + for i in range(len(items)): + for j in range(len(items)): + if i == j: + continue + ca, (va, da) = items[i] + cb, (vb, db2) = items[j] + if ca != "KZ" or cb == "KZ": + continue # only KZ → other pairs + delta = vb - va + pct = (delta / va * 100) if va else 0 + out.append([p, ca, cb, round(va, 2), round(vb, 2), round(delta, 2), + round(pct, 1), f"{da[:10]} / {db2[:10]}"]) + return out + + +def _sheet_alerts(limit: int = 100) -> list[list[str]]: + rows = db.recent_alerts(limit=limit) + out = [["Вид", "Товар", "Регион", "Сообщение", "Серьёзность", "Создан"]] + for a in rows: + out.append([a.get("kind", ""), a.get("product", ""), a.get("region", ""), + a.get("message", ""), a.get("severity", ""), + (a.get("created_at") or "")[:16]]) + return out + + +def build_sheets(days: int) -> dict[str, list[list[str]]]: + products = [r["product"] for r in + db.exec_sql("SELECT DISTINCT product FROM prices WHERE quarantined=0 " + "ORDER BY product")][:8] + sheets: dict[str, list[list[str]]] = {} + for p in products: + short = p.replace("_", " ")[:31] + sheets[short[:31]] = _sheet_dynamics(p, days) + sheets["Раскладки"] = _sheet_spreads(days) + sheets["Алерты"] = _sheet_alerts(limit=100) + return sheets + + +def to_xlsx(sheets: dict[str, list[list[str]]], out_path: str) -> bool: + """Write each sheet to a temp CSV, then call the host makexlsx with a JSON of sheets.""" + payload = {name: rows for name, rows in sheets.items() if rows} + if not payload: + print("Нет данных для выгрузки.") + return False + proc = subprocess.run(["makexlsx", out_path], input=json.dumps(payload), + text=True, capture_output=True) + if proc.returncode != 0: + print("makexlsx error:", proc.stderr[:400]) + return False + return True + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=str(Path("data") / "agromarket_report.xlsx")) + ap.add_argument("--days", type=int, default=30) + args = ap.parse_args() + os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) + sheets = build_sheets(args.days) + ok = to_xlsx(sheets, args.out) + if ok: + print(f"Готово: {args.out} | листов: {len(sheets)}") + for name in sheets: + print(f" - {name}: {len(sheets[name])} строк") + + +if __name__ == "__main__": + main()
ТоварОткудаКуда A, тг/кг