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:
wasrusgen 2026-05-19 09:44:34 +03:00
parent 12dec17ed1
commit 76fce9ec58
7 changed files with 633 additions and 0 deletions

View 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}

View File

@ -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", ""),
)

View File

@ -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 ПЭП)
# =================================================================

View File

@ -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

View File

@ -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();

View 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, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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… может занять 1020 сек</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 };
})();

View File

@ -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>