mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 14:04:48 +00:00
feat(analytics): assembler schedule parser + analytics screen
Backend: - assembler_parser.py: parse Excel «Таблица занятости сборщиков» - Handles both row-order variants (2026: dates row1; 2025-: dates row2) - Extracts amount from end of cell text, supports compound "6030+20100" - aggregate(): by_assembler×month + by_month totals - In-memory cache with mtime invalidation - main.py: /api/assembler_analytics — local file first, Drive fallback - LOCAL: /app/data/assembler_schedule.xlsx (mounted volume) - Config: ASSEMBLER_SCHEDULE_PATH env var override - config.py: assembler_schedule_file_id for Drive fallback - docker-compose.yml: /opt/zov-tech/data → /app/data:ro volume Frontend: - assembler_analytics.js: year filter, monthly table, assembler ranking with progress bars, per-order average, last-6-months breakdown - app.js: route #/admin/assembler-analytics + "Аналитика" button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
12dec17ed1
commit
76fce9ec58
253
backend-py/app/assembler_parser.py
Normal file
253
backend-py/app/assembler_parser.py
Normal file
@ -0,0 +1,253 @@
|
||||
"""
|
||||
Парсер таблицы занятости сборщиков (Excel).
|
||||
|
||||
Формат файла:
|
||||
Col A: зона (север, охта, ...)
|
||||
Col B: ФИО + телефоны (всё в одной ячейке)
|
||||
Col C: тип строки ('сборка' / 'замер' / 'только замеры')
|
||||
Col D+: ячейки заказов, одна колонка = один день
|
||||
|
||||
Строка 1 (2026+): даты
|
||||
Строка 2 (2026+): дни недели
|
||||
--- VS ---
|
||||
Строка 1 (до 2026): дни недели
|
||||
Строка 2 (до 2026): даты
|
||||
|
||||
Стоимость сборки — последнее число в тексте ячейки:
|
||||
'1322Б Парголово ул.Заречная 10 кв105 89110064400 Алексеев А.К. 63900'
|
||||
Составная: '6030+20100' → 26130
|
||||
Доделка без суммы: '' или 'Доделка ...' → 0
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
openpyxl = None # type: ignore
|
||||
|
||||
log = logging.getLogger("zov.assembler_parser")
|
||||
|
||||
# Кэш: {path: {mtime, data}}
|
||||
_cache: dict[str, dict] = {}
|
||||
|
||||
_AMOUNT_RE = re.compile(r"([\d]+(?:[+][\d]+)*)\s*$")
|
||||
_COMPOUND_RE = re.compile(r"^\d+(?:[+]\d+)+$")
|
||||
|
||||
|
||||
def _extract_amount(text: str) -> int:
|
||||
"""Извлекает стоимость из конца текста ячейки."""
|
||||
text = (text or "").strip()
|
||||
m = _AMOUNT_RE.search(text)
|
||||
if not m:
|
||||
return 0
|
||||
raw = m.group(1)
|
||||
if _COMPOUND_RE.match(raw):
|
||||
return sum(int(x) for x in raw.split("+"))
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _extract_name(cell_b: str) -> str:
|
||||
"""Извлекает ФИО из первой строки или до первого телефона."""
|
||||
s = (cell_b or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# Первая строка
|
||||
first_line = s.split("\n")[0].strip()
|
||||
# Обрезаем по телефону (8-9xx, +7)
|
||||
m = re.search(r"[\s,](?:8[-\s]?9|(?:\+7))\d", first_line)
|
||||
if m:
|
||||
first_line = first_line[: m.start()].strip()
|
||||
return first_line or s.split("\n")[0]
|
||||
|
||||
|
||||
def _is_date_row(row_vals: list) -> bool:
|
||||
"""Проверяет, содержит ли строка datetime-объекты (ряд с датами)."""
|
||||
dates = [v for v in row_vals if isinstance(v, (datetime, date))]
|
||||
return len(dates) >= 5
|
||||
|
||||
|
||||
def parse_sheet(ws) -> list[dict]:
|
||||
"""
|
||||
Парсит один лист. Возвращает список записей:
|
||||
{name, zone, date, order_text, amount, sheet_title}
|
||||
"""
|
||||
if openpyxl is None:
|
||||
return []
|
||||
|
||||
title = ws.title
|
||||
rows = list(ws.iter_rows(min_row=1, max_row=ws.max_row, values_only=True))
|
||||
if len(rows) < 3:
|
||||
return []
|
||||
|
||||
# Определяем строку с датами (row index 0 или 1)
|
||||
date_row_idx = None
|
||||
for i in (0, 1):
|
||||
if _is_date_row(rows[i]):
|
||||
date_row_idx = i
|
||||
break
|
||||
if date_row_idx is None:
|
||||
return []
|
||||
|
||||
date_row = rows[date_row_idx]
|
||||
# Строим словарь col_index → date
|
||||
col_to_date: dict[int, date] = {}
|
||||
for ci, v in enumerate(date_row):
|
||||
if ci < 3: # A, B, C — не даты
|
||||
continue
|
||||
if isinstance(v, datetime):
|
||||
col_to_date[ci] = v.date()
|
||||
elif isinstance(v, date):
|
||||
col_to_date[ci] = v
|
||||
|
||||
if not col_to_date:
|
||||
return []
|
||||
|
||||
records: list[dict] = []
|
||||
current_assembler: dict | None = None # {name, zone}
|
||||
|
||||
for ri, row in enumerate(rows):
|
||||
if ri <= date_row_idx:
|
||||
continue
|
||||
|
||||
col_a = str(row[0] or "").strip().lower()
|
||||
col_b = str(row[1] or "").strip()
|
||||
col_c = str(row[2] or "").strip().lower()
|
||||
|
||||
# Строка сборщика
|
||||
if col_b and col_c in ("сборка", "сборка "):
|
||||
name = _extract_name(col_b)
|
||||
zone = col_a or ""
|
||||
current_assembler = {"name": name, "zone": zone}
|
||||
|
||||
elif col_c in ("замер", "замер ", "только замеры"):
|
||||
# Строка замеров — пропускаем (не относится к стоимости)
|
||||
pass
|
||||
|
||||
else:
|
||||
current_assembler = None # Разрыв блока
|
||||
continue
|
||||
|
||||
if not current_assembler:
|
||||
continue
|
||||
|
||||
# Собираем заказы из ячеек (cols D+)
|
||||
for ci, cell_val in enumerate(row):
|
||||
if ci < 3:
|
||||
continue
|
||||
if ci not in col_to_date:
|
||||
continue
|
||||
text = str(cell_val or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
amount = _extract_amount(text)
|
||||
if amount == 0 and not text:
|
||||
continue
|
||||
records.append({
|
||||
"assembler_name": current_assembler["name"],
|
||||
"assembler_zone": current_assembler["zone"],
|
||||
"date": col_to_date[ci].isoformat(),
|
||||
"order_text": text[:120],
|
||||
"amount": amount,
|
||||
"sheet": title,
|
||||
})
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def parse_file(xlsx_path: str, sheets_filter: list[str] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Парсит Excel файл. Кэширует по mtime.
|
||||
sheets_filter: список названий листов для парсинга; None = все.
|
||||
"""
|
||||
if openpyxl is None:
|
||||
return {"error": "openpyxl not installed", "records": []}
|
||||
|
||||
path = str(xlsx_path)
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return {"error": f"file_not_found: {path}", "records": []}
|
||||
|
||||
if path in _cache and _cache[path]["mtime"] == mtime:
|
||||
return _cache[path]["data"]
|
||||
|
||||
log.info("Parsing assembler schedule: %s", path)
|
||||
t0 = time.time()
|
||||
try:
|
||||
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "records": []}
|
||||
|
||||
all_records: list[dict] = []
|
||||
parsed_sheets = []
|
||||
for sname in wb.sheetnames:
|
||||
if sheets_filter and sname not in sheets_filter:
|
||||
continue
|
||||
# Пропускаем служебные листы
|
||||
if sname.lower().startswith("лист") or sname.lower() == "образец":
|
||||
continue
|
||||
try:
|
||||
ws = wb[sname]
|
||||
recs = parse_sheet(ws)
|
||||
all_records.extend(recs)
|
||||
parsed_sheets.append({"sheet": sname, "records": len(recs)})
|
||||
except Exception as e:
|
||||
log.warning("Sheet %s parse error: %s", sname, e)
|
||||
|
||||
wb.close()
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
log.info("Parsed %d records in %.2fs", len(all_records), elapsed)
|
||||
|
||||
data = {
|
||||
"records": all_records,
|
||||
"parsed_sheets": parsed_sheets,
|
||||
"elapsed_s": elapsed,
|
||||
"parsed_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
_cache[path] = {"mtime": mtime, "data": data}
|
||||
return data
|
||||
|
||||
|
||||
def aggregate(records: list[dict]) -> dict[str, Any]:
|
||||
"""
|
||||
Агрегирует записи по сборщику и месяцу.
|
||||
Возвращает:
|
||||
by_assembler: {name: {year_month: {orders, total_amount}}}
|
||||
by_month: {year_month: {total_amount, order_count, assemblers}}
|
||||
"""
|
||||
by_assembler: dict[str, dict] = {}
|
||||
by_month: dict[str, dict] = {}
|
||||
|
||||
for r in records:
|
||||
name = r["assembler_name"]
|
||||
dt = r["date"][:7] # YYYY-MM
|
||||
amount = r["amount"]
|
||||
|
||||
# by_assembler
|
||||
if name not in by_assembler:
|
||||
by_assembler[name] = {}
|
||||
if dt not in by_assembler[name]:
|
||||
by_assembler[name][dt] = {"orders": 0, "total_amount": 0, "zone": r.get("assembler_zone", "")}
|
||||
by_assembler[name][dt]["orders"] += 1
|
||||
by_assembler[name][dt]["total_amount"] += amount
|
||||
|
||||
# by_month
|
||||
if dt not in by_month:
|
||||
by_month[dt] = {"total_amount": 0, "order_count": 0, "assemblers": set()}
|
||||
by_month[dt]["total_amount"] += amount
|
||||
by_month[dt]["order_count"] += 1
|
||||
by_month[dt]["assemblers"].add(name)
|
||||
|
||||
# Сериализуем sets
|
||||
for k in by_month:
|
||||
by_month[k]["assemblers"] = sorted(by_month[k]["assemblers"])
|
||||
|
||||
return {"by_assembler": by_assembler, "by_month": by_month}
|
||||
@ -30,6 +30,8 @@ class Config:
|
||||
shipments_file_id: str
|
||||
# Google Drive ID файла «Поступление заказов на склад СПб.xlsx»
|
||||
arrivals_file_id: str
|
||||
# Google Drive ID «Таблица занятости сборщиков.xlsx»
|
||||
assembler_schedule_file_id: str
|
||||
|
||||
|
||||
def _required(name: str) -> str:
|
||||
@ -59,4 +61,6 @@ def get_config() -> Config:
|
||||
shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1KCJUXjhVR2NWEz9bD0kjTaEADsxF8gI5GMzLwJ2bw84"),
|
||||
# Поступление заказов на склад СПб — тот же файл что ОТГРУЗКИ
|
||||
arrivals_file_id=os.getenv("ARRIVALS_FILE_ID", "1KCJUXjhVR2NWEz9bD0kjTaEADsxF8gI5GMzLwJ2bw84"),
|
||||
# Таблица занятости сборщиков — передать file_id через env
|
||||
assembler_schedule_file_id=os.getenv("ASSEMBLER_SCHEDULE_FILE_ID", ""),
|
||||
)
|
||||
|
||||
@ -20,6 +20,7 @@ from .auth import verify_init_data
|
||||
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive
|
||||
from . import parsers
|
||||
from . import proposals as proposals_mod
|
||||
from . import assembler_parser
|
||||
from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
@ -151,6 +152,7 @@ async def _dispatch_post(request: Request):
|
||||
"assembly_rates_list": _handle_assembly_rates_list,
|
||||
"assembly_rate_save": _handle_assembly_rate_save,
|
||||
"assembly_rate_delete": _handle_assembly_rate_delete,
|
||||
"assembler_analytics": _handle_assembler_analytics,
|
||||
"proposal_brief": proposals_mod.handle_brief,
|
||||
"proposal_create": proposals_mod.handle_create,
|
||||
"proposal_upsert_variant": proposals_mod.handle_upsert_variant,
|
||||
@ -2831,6 +2833,138 @@ def _handle_assembly_rate_delete(body: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Assembler Analytics — парсинг таблицы занятости сборщиков
|
||||
# =================================================================
|
||||
|
||||
# Кэш распарсенного Excel в памяти (drive bytes → parse → aggregate)
|
||||
_schedule_cache: dict = {"data": None, "ts": 0.0, "etag": ""}
|
||||
_SCHEDULE_CACHE_TTL = 600 # 10 минут
|
||||
|
||||
|
||||
_LOCAL_SCHEDULE_PATH = os.environ.get(
|
||||
"ASSEMBLER_SCHEDULE_PATH",
|
||||
"/app/data/assembler_schedule.xlsx"
|
||||
)
|
||||
|
||||
|
||||
def _get_schedule_data() -> dict:
|
||||
"""Парсит таблицу занятости сборщиков. Кэш 10 мин.
|
||||
Источник: локальный файл (LOCAL) или Google Drive (DRIVE)."""
|
||||
import time as _time
|
||||
now = _time.monotonic()
|
||||
|
||||
if _schedule_cache["data"] and (now - _schedule_cache["ts"]) < _SCHEDULE_CACHE_TTL:
|
||||
return _schedule_cache["data"]
|
||||
|
||||
# Пробуем локальный файл
|
||||
if os.path.exists(_LOCAL_SCHEDULE_PATH):
|
||||
log.info("assembler_schedule: using local file %s", _LOCAL_SCHEDULE_PATH)
|
||||
parsed = assembler_parser.parse_file(_LOCAL_SCHEDULE_PATH)
|
||||
else:
|
||||
# Fallback: Google Drive
|
||||
cfg = get_config()
|
||||
file_id = cfg.assembler_schedule_file_id
|
||||
if not file_id:
|
||||
return {"error": "Файл не найден локально и ASSEMBLER_SCHEDULE_FILE_ID не задан"}
|
||||
try:
|
||||
xlsx_bytes = drive.download_file_bytes(file_id)
|
||||
except Exception as e:
|
||||
log.warning("assembler_schedule download error: %s", e)
|
||||
return {"error": f"download_failed: {e}"}
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tf:
|
||||
tf.write(xlsx_bytes)
|
||||
tmp_path = tf.name
|
||||
try:
|
||||
parsed = assembler_parser.parse_file(tmp_path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "error" in parsed:
|
||||
return parsed
|
||||
|
||||
records = parsed.get("records", [])
|
||||
agg = assembler_parser.aggregate(records)
|
||||
|
||||
data = {
|
||||
"ok": True,
|
||||
"parsed_sheets": parsed.get("parsed_sheets", []),
|
||||
"total_records": len(records),
|
||||
"elapsed_s": parsed.get("elapsed_s"),
|
||||
"parsed_at": parsed.get("parsed_at"),
|
||||
"by_assembler": agg["by_assembler"],
|
||||
"by_month": agg["by_month"],
|
||||
}
|
||||
_schedule_cache["data"] = data
|
||||
_schedule_cache["ts"] = now
|
||||
return data
|
||||
|
||||
|
||||
def _handle_assembler_analytics(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Возвращает аналитику занятости/стоимостей сборщиков.
|
||||
body: {initData, year?: '2026', assembler_name?: 'Иванов И.И.'}
|
||||
Доступен менеджеру."""
|
||||
cfg = get_config()
|
||||
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||
if not auth or not auth.get("user"):
|
||||
unsafe = body.get("initDataUnsafe") or {}
|
||||
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
|
||||
auth = {"user": unsafe["user"]}
|
||||
else:
|
||||
return {"error": "invalid_init_data"}
|
||||
tg_id = auth["user"]["id"]
|
||||
user = sheets.find_user(tg_id)
|
||||
if not user or not (sheets.has_role(user, "manager") or sheets.has_role(user, "admin")):
|
||||
return {"error": "forbidden"}
|
||||
|
||||
data = _get_schedule_data()
|
||||
if "error" in data:
|
||||
return data
|
||||
|
||||
# Фильтр по году (если указан)
|
||||
year = str(body.get("year") or "").strip()
|
||||
if year and year.isdigit():
|
||||
by_month = {k: v for k, v in data["by_month"].items() if k.startswith(year)}
|
||||
by_assembler = {}
|
||||
for name, months in data["by_assembler"].items():
|
||||
filtered = {k: v for k, v in months.items() if k.startswith(year)}
|
||||
if filtered:
|
||||
by_assembler[name] = filtered
|
||||
else:
|
||||
by_month = data["by_month"]
|
||||
by_assembler = data["by_assembler"]
|
||||
|
||||
# Топ-5 сборщиков по итоговой сумме
|
||||
assembler_totals = [
|
||||
{
|
||||
"name": name,
|
||||
"total_amount": sum(m["total_amount"] for m in months.values()),
|
||||
"total_orders": sum(m["orders"] for m in months.values()),
|
||||
"months": months,
|
||||
}
|
||||
for name, months in by_assembler.items()
|
||||
]
|
||||
assembler_totals.sort(key=lambda x: x["total_amount"], reverse=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"parsed_at": data.get("parsed_at"),
|
||||
"total_records": data.get("total_records"),
|
||||
"by_month": by_month,
|
||||
"assemblers": assembler_totals,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/assembler_analytics")
|
||||
async def api_assembler_analytics(request: Request):
|
||||
body = await _safe_json(request)
|
||||
return _handle_assembler_analytics(body)
|
||||
|
||||
|
||||
# =================================================================
|
||||
# SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП)
|
||||
# =================================================================
|
||||
|
||||
@ -11,6 +11,7 @@ services:
|
||||
volumes:
|
||||
- ./credentials.json:/app/credentials.json:ro
|
||||
- ./photos:/app/photos
|
||||
- /opt/zov-tech/data:/app/data:ro
|
||||
networks:
|
||||
- web # внешняя сеть от deploy-стека (Caddy там)
|
||||
- internal
|
||||
|
||||
@ -195,6 +195,7 @@ async function renderManagerHome(me) {
|
||||
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
||||
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
|
||||
{ icon: "wrench", title: "Ставки сборки", subtitle: "% клиент / сборщик", href: "#/admin/rates" },
|
||||
{ icon: "folder", title: "Аналитика", subtitle: "Занятость сборщиков", href: "#/admin/assembler-analytics" },
|
||||
];
|
||||
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
||||
const grid = el(`<div class="quick-grid"></div>`);
|
||||
@ -1696,6 +1697,9 @@ function routeByHash() {
|
||||
else init();
|
||||
} else if (location.hash.startsWith("#/assembly")) {
|
||||
Assembly.mount(app);
|
||||
} else if (location.hash === "#/admin/assembler-analytics") {
|
||||
if (typeof AssemblerAnalytics !== "undefined") AssemblerAnalytics.mount(app);
|
||||
else init();
|
||||
} else if (location.hash.startsWith("#/admin/rates")) {
|
||||
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
|
||||
else init();
|
||||
|
||||
236
miniapp/assets/assembler_analytics.js
Normal file
236
miniapp/assets/assembler_analytics.js
Normal file
@ -0,0 +1,236 @@
|
||||
/* ============================================================
|
||||
AssemblerAnalytics — аналитика занятости сборщиков
|
||||
#/admin/assembler-analytics
|
||||
Данные: «Таблица занятости сборщиков.xlsx» → backend parser
|
||||
============================================================ */
|
||||
|
||||
const AssemblerAnalytics = (function () {
|
||||
"use strict";
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
function el(html) {
|
||||
const t = document.createElement("template");
|
||||
t.innerHTML = html.trim();
|
||||
return t.content.firstChild;
|
||||
}
|
||||
function fmtMoney(n) {
|
||||
return Math.round(n || 0).toLocaleString("ru-RU") + " ₽";
|
||||
}
|
||||
function fmtMonth(ym) {
|
||||
// "2026-05" → "Май 2026"
|
||||
try {
|
||||
const d = new Date(ym + "-01");
|
||||
return d.toLocaleDateString("ru-RU", { month: "long", year: "numeric" });
|
||||
} catch { return ym; }
|
||||
}
|
||||
|
||||
async function _api(path, body = {}) {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 30000); // парсинг Excel — долгий
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
|
||||
method: "POST", signal: ctrl.signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")),
|
||||
initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null),
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") throw new Error("Таймаут — файл большой, попробуй ещё раз");
|
||||
throw e;
|
||||
} finally { clearTimeout(t); }
|
||||
}
|
||||
|
||||
/* ── Главный экран ─────────────────────────────────────────── */
|
||||
async function mount(container) {
|
||||
container.innerHTML = "";
|
||||
document.body.classList.remove("has-bottom-nav");
|
||||
document.getElementById("bottom-nav")?.remove();
|
||||
|
||||
const h = el(`
|
||||
<header class="podbor-header">
|
||||
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || "‹"}</button>
|
||||
<div class="podbor-title">Аналитика сборщиков</div>
|
||||
<button id="reloadBtn" style="background:none;border:none;font-size:18px;cursor:pointer;padding:4px 8px;" title="Обновить">↻</button>
|
||||
</header>
|
||||
`);
|
||||
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
history.back();
|
||||
});
|
||||
|
||||
const screen = el(`<div class="podbor-screen"></div>`);
|
||||
container.appendChild(h);
|
||||
container.appendChild(screen);
|
||||
|
||||
const yearEl = el(`
|
||||
<div style="padding:0 16px 8px;display:flex;align-items:center;gap:10px;">
|
||||
<label style="font-size:12px;color:var(--muted);">Год:</label>
|
||||
<select id="yearSelect" style="padding:5px 10px;border:1px solid var(--border);border-radius:8px;
|
||||
background:var(--surface);color:var(--ink);font-size:13px;">
|
||||
<option value="">Все</option>
|
||||
<option value="2026" selected>2026</option>
|
||||
<option value="2025">2025</option>
|
||||
<option value="2024">2024</option>
|
||||
</select>
|
||||
</div>
|
||||
`);
|
||||
container.insertBefore(yearEl, screen);
|
||||
|
||||
const load = async (year) => {
|
||||
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div><div style="margin-top:8px;font-size:12px;color:var(--muted);">Парсим Excel… может занять 10–20 сек</div></div>`;
|
||||
try {
|
||||
const data = await _api("assembler_analytics", { year });
|
||||
if (data.error) {
|
||||
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
_render(screen, data, year);
|
||||
} catch (e) {
|
||||
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
yearEl.querySelector("#yearSelect").addEventListener("change", function () {
|
||||
load(this.value);
|
||||
});
|
||||
h.querySelector("#reloadBtn").addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
load(yearEl.querySelector("#yearSelect").value);
|
||||
});
|
||||
|
||||
load("2026");
|
||||
}
|
||||
|
||||
/* ── Рендер данных ─────────────────────────────────────────── */
|
||||
function _render(screen, data, year) {
|
||||
screen.innerHTML = "";
|
||||
|
||||
const parsedAt = data.parsed_at ? new Date(data.parsed_at).toLocaleString("ru-RU") : "—";
|
||||
screen.appendChild(el(`
|
||||
<div style="margin:0 16px 12px;font-size:11px;color:var(--muted);">
|
||||
Обновлено: ${escHtml(parsedAt)} · Записей: ${escHtml(String(data.total_records || 0))}
|
||||
</div>
|
||||
`));
|
||||
|
||||
// === Итоги по месяцам ===
|
||||
const byMonth = data.by_month || {};
|
||||
const months = Object.keys(byMonth).sort();
|
||||
if (months.length) {
|
||||
screen.appendChild(el(`<div class="section-head"><span class="label">📅 По месяцам</span></div>`));
|
||||
const monthWrap = el(`<div style="overflow-x:auto;padding:0 16px 8px;"></div>`);
|
||||
const table = el(`
|
||||
<table style="border-collapse:collapse;width:100%;min-width:400px;font-size:12px;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid var(--border);">
|
||||
<th style="text-align:left;padding:6px 8px;color:var(--muted);font-weight:600;">Месяц</th>
|
||||
<th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600;">Заказов</th>
|
||||
<th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600;">Сумма сборок</th>
|
||||
<th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600;">Сборщиков</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="monthTbody"></tbody>
|
||||
</table>
|
||||
`);
|
||||
const tbody = table.querySelector("#monthTbody");
|
||||
let grandTotal = 0, grandOrders = 0;
|
||||
for (const ym of months.reverse()) {
|
||||
const m = byMonth[ym];
|
||||
grandTotal += m.total_amount || 0;
|
||||
grandOrders += m.order_count || 0;
|
||||
const tr = el(`
|
||||
<tr style="border-bottom:1px solid var(--border);">
|
||||
<td style="padding:7px 8px;font-weight:500;">${escHtml(fmtMonth(ym))}</td>
|
||||
<td style="padding:7px 8px;text-align:right;">${escHtml(String(m.order_count || 0))}</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-weight:600;color:var(--accent);">${escHtml(fmtMoney(m.total_amount))}</td>
|
||||
<td style="padding:7px 8px;text-align:right;color:var(--muted);">${escHtml(String((m.assemblers || []).length))}</td>
|
||||
</tr>
|
||||
`);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
// Итого строка
|
||||
tbody.appendChild(el(`
|
||||
<tr style="border-top:2px solid var(--border);background:var(--surface);">
|
||||
<td style="padding:7px 8px;font-weight:700;">ИТОГО</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-weight:700;">${grandOrders}</td>
|
||||
<td style="padding:7px 8px;text-align:right;font-weight:700;color:var(--accent);">${escHtml(fmtMoney(grandTotal))}</td>
|
||||
<td style="padding:7px 8px;"></td>
|
||||
</tr>
|
||||
`));
|
||||
monthWrap.appendChild(table);
|
||||
screen.appendChild(monthWrap);
|
||||
}
|
||||
|
||||
// === Рейтинг сборщиков ===
|
||||
const assemblers = (data.assemblers || []);
|
||||
if (assemblers.length) {
|
||||
screen.appendChild(el(`
|
||||
<div class="section-head" style="margin-top:20px;">
|
||||
<span class="label">👷 Сборщики · ${assemblers.length}</span>
|
||||
</div>
|
||||
`));
|
||||
|
||||
const maxAmt = Math.max(...assemblers.map(a => a.total_amount)) || 1;
|
||||
|
||||
assemblers.forEach((a, idx) => {
|
||||
const barPct = Math.round((a.total_amount / maxAmt) * 100);
|
||||
const avgPerOrder = a.total_orders ? Math.round(a.total_amount / a.total_orders) : 0;
|
||||
// Раскладка по месяцам: последние 6
|
||||
const monthKeys = Object.keys(a.months || {}).sort().slice(-6);
|
||||
const monthCells = monthKeys.map(ym => {
|
||||
const mm = a.months[ym];
|
||||
return `<div style="flex:1;text-align:center;">
|
||||
<div style="font-size:9px;color:var(--muted);">${ym.slice(5)}</div>
|
||||
<div style="font-size:11px;font-weight:600;">${Math.round((mm.total_amount||0)/1000)}к</div>
|
||||
<div style="font-size:9px;color:var(--muted);">${mm.orders} зак.</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const card = el(`
|
||||
<div style="margin:6px 16px;padding:12px 14px;background:var(--surface);
|
||||
border:1px solid var(--border);border-radius:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px;">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:700;color:var(--ink);">${idx + 1}. ${escHtml(a.name)}</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:1px;">
|
||||
${a.total_orders} заказов · ср. ${escHtml(fmtMoney(avgPerOrder))} / заказ
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0;">
|
||||
<div style="font-size:16px;font-weight:700;color:var(--accent);">${escHtml(fmtMoney(a.total_amount))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Прогресс-бар -->
|
||||
<div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:8px;">
|
||||
<div style="height:4px;background:var(--accent);border-radius:2px;width:${barPct}%;"></div>
|
||||
</div>
|
||||
<!-- Месяцы -->
|
||||
${monthCells ? `<div style="display:flex;gap:4px;margin-top:4px;">${monthCells}</div>` : ""}
|
||||
</div>
|
||||
`);
|
||||
screen.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
if (!months.length && !assemblers.length) {
|
||||
screen.innerHTML = `
|
||||
<div style="text-align:center;padding:40px 16px;color:var(--muted);">
|
||||
Нет данных за выбранный период.<br>
|
||||
<span style="font-size:12px;">Попробуй выбрать другой год.</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
screen.appendChild(el(`<div style="height:32px;"></div>`));
|
||||
}
|
||||
|
||||
return { mount };
|
||||
})();
|
||||
@ -54,6 +54,7 @@
|
||||
<script src="assets/master_tools.js?v=20260518p"></script>
|
||||
<script src="assets/signrequest.js?v=20260518o"></script>
|
||||
<script src="assets/admin_rates.js?v=20260519a"></script>
|
||||
<script src="assets/assembler_analytics.js?v=20260519a"></script>
|
||||
<script src="assets/assembly_detail.js?v=20260519a"></script>
|
||||
<script src="assets/app.js?v=20260519a"></script>
|
||||
</body>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user