"""Статистика: периоды, недели, месяцы, годы, товары и люди. Экран намеренно ничего не считает сам — все цифры приходят из app/stats.py. Здесь только показ: выбор разреза, график, таблица и строка итогов. """ from __future__ import annotations from decimal import Decimal from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QTabWidget, QVBoxLayout, QWidget, ) from .. import money as m from .. import stats from ..ledger import STATUS_LABELS from . import theme from . import widgets as w from .chart import Bar, BarChart # Сколько последних корзин показывать на графике. Дальше столбики становятся # уже, чем подпись под ними, и картинка превращается в кашу. CHART_LIMIT = 24 METRICS = [ ("Прибыль", "profit", theme.OK), ("Выручка", "revenue", theme.ACCENT), ("Собрано деньгами", "collected", theme.ACCENT), ("Наценка", "margin", theme.OK), ("Чаевые", "tips", theme.OK), ("Закуплено", "purchased", theme.WARN), ("Отдано пекарне", "paid_to_bakery", theme.WARN), ("Прощено пекарней", "bakery_discount", theme.OK), ] BUCKET_COLUMNS = [ "Период", "Продаж", "Продано", "Выручка", "Себестоимость", "Наценка", "Чаевые", "Съедено", "Прибыль", "Собрано", "Закуплено", "Отдано пекарне", "Прощено", ] def money_cell(value: Decimal, currency: str, color: str = "", dim_zero: bool = True): """Ячейка с суммой. Ноль приглушаем — глаз должен цепляться за цифры.""" if value == 0 and dim_zero: return w.sortable_num_item("—", 0, theme.MUTED) return w.sortable_num_item(m.fmt_money(value, currency), value, color) def signed_color(value: Decimal) -> str: if value > 0: return theme.OK return theme.DANGER if value < 0 else "" class BucketTab(QWidget): """Один временной разрез: график сверху, таблица снизу, итоги в конце.""" def __init__(self, ctx, grain: str, parent=None): super().__init__(parent) self.ctx = ctx self.grain = grain layout = QVBoxLayout(self) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(10) # Не self.metric: у QWidget есть метод metric(), который Qt зовёт # при смене стиля. Атрибут его затеняет, и приложение падает # с невнятным «object is not callable». self.metric_box = QComboBox() for title, attribute, color in METRICS: self.metric_box.addItem(title, (attribute, color)) self.metric_box.currentIndexChanged.connect(self._draw_chart) self.headline = w.label("", "h2") layout.addWidget(w.row(w.label("На графике:"), self.metric_box, None, self.headline)) self.chart = BarChart() layout.addWidget(self.chart) self.table = w.table(BUCKET_COLUMNS) self.table.setSortingEnabled(False) # хронология важнее сортировки layout.addWidget(self.table, 1) def refresh(self) -> None: self.buckets = stats.build_buckets(self.ctx.vault.doc, self.ctx.report, self.grain) currency = self.ctx.currency rows = [] # Свежее сверху: в таблицу смотрят, чтобы увидеть последнее. for bucket in reversed(self.buckets): rows.append(self._row(bucket, currency)) if self.buckets: total = stats.totals(self.buckets) rows.append(self._row(total, currency, bold=True)) w.fill(self.table, rows) self._draw_chart() def _row(self, bucket: stats.Bucket, currency: str, bold: bool = False): profit = bucket.profit # Подписи недель длинные и в узкой колонке ужимаются многоточием — # полный текст остаётся доступен наведением. label = w.text_item(bucket.label, bold=bold) label.setToolTip(f"{bucket.label}\n{bucket.start:%d.%m.%Y} — {bucket.end:%d.%m.%Y}") return [ label, w.sortable_num_item(str(bucket.sales_count) if bucket.sales_count else "—", bucket.sales_count, "" if bucket.sales_count else theme.MUTED, bold), w.text_item(bucket.qty_text or "—", "" if bucket.qty_text else theme.MUTED, bold), money_cell(bucket.revenue, currency), money_cell(bucket.cogs, currency), money_cell(bucket.margin, currency, theme.OK if bucket.margin else ""), money_cell(bucket.tips, currency, theme.OK if bucket.tips else ""), money_cell(bucket.consumed_cost, currency, theme.DANGER if bucket.consumed_cost else ""), money_cell(profit, currency, signed_color(profit), dim_zero=False), money_cell(bucket.collected, currency), money_cell(bucket.purchased, currency), money_cell(bucket.paid_to_bakery, currency), money_cell(bucket.bakery_discount, currency, theme.OK if bucket.bakery_discount else ""), ] def _draw_chart(self) -> None: attribute, color = self.metric_box.currentData() shown = self.buckets[-CHART_LIMIT:] currency = self.ctx.currency self.chart.set_bars( [ Bar( label=self._short(bucket), value=getattr(bucket, attribute), tooltip=f"{bucket.label}\n{self.metric_box.currentText()}: " f"{m.fmt_money(getattr(bucket, attribute), currency)}", ) for bucket in shown ], currency, color, ) total = sum((getattr(b, attribute) for b in self.buckets), m.ZERO) hidden = len(self.buckets) - len(shown) # Про срез говорим вслух: молча показанная часть выглядела бы как всё. tail = f" · на графике последние {len(shown)} из {len(self.buckets)}" if hidden else "" self.headline.setText( f"{self.metric_box.currentText()} за всё время: {m.fmt_money(total, currency)}{tail}" ) def _short(self, bucket: stats.Bucket) -> str: if self.grain == stats.GRAIN_WEEK: return bucket.start.strftime("%d.%m") if self.grain == stats.GRAIN_MONTH: return bucket.start.strftime("%m.%y") return bucket.label.replace(" год", "") class PeriodsTab(QWidget): """Периоды, которые задавались руками, — то есть партии.""" COLUMNS = [ "Партия", "Дата", "Покрыть до", "Дней", "Долг", "Отдано", "Прощено", "Собрано", "Покрытие", "Продано", "Осталось", "Наценка", "Чаевые", "Съедено", "Прибыль", "Статус", ] def __init__(self, ctx, parent=None): super().__init__(parent) self.ctx = ctx layout = QVBoxLayout(self) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(10) self.headline = w.label("", "h2") layout.addWidget( w.row(w.label("Каждая закупка — период со своим сроком расчёта.", "dim"), None, self.headline) ) self.chart = BarChart() layout.addWidget(self.chart) self.table = w.table(self.COLUMNS) self.table.setSortingEnabled(False) layout.addWidget(self.table, 1) def refresh(self) -> None: periods = stats.build_periods(self.ctx.vault.doc, self.ctx.report) currency = self.ctx.currency rows = [] for period in reversed(periods): report = period.report color = theme.STATUS_COLORS.get(report.status, "") rows.append( [ w.text_item(f"№{period.batch.number}"), w.text_item(period.batch.date.strftime("%d.%m.%Y")), w.text_item(period.batch.due_date.strftime("%d.%m.%Y")), w.sortable_num_item(str(period.days_total), period.days_total), money_cell(report.cost_total, currency), money_cell(report.paid_to_bakery, currency), money_cell(report.discount, currency, theme.OK if report.discount else ""), money_cell(report.cash_collected, currency), w.sortable_num_item(f"{report.coverage_pct}%", report.coverage_pct, color), w.text_item(period.sold_text or "—", "" if period.sold_text else theme.MUTED), w.text_item(period.left_text or "—", "" if period.left_text else theme.MUTED), money_cell(period.margin, currency, theme.OK if period.margin else ""), money_cell(period.tips, currency, theme.OK if period.tips else ""), money_cell(report.consumed_cost, currency, theme.DANGER if report.consumed_cost else ""), money_cell(period.profit, currency, signed_color(period.profit), dim_zero=False), w.text_item(STATUS_LABELS.get(report.status, ""), color), ] ) w.fill(self.table, rows) self.chart.set_bars( [ Bar( label=f"№{p.batch.number}", value=p.profit, tooltip=f"Партия {p.label}\nПрибыль: {m.fmt_money(p.profit, currency)}\n" f"Покрытие: {p.report.coverage_pct}%", ) for p in periods[-CHART_LIMIT:] ], currency, theme.OK, ) earned = sum((p.profit for p in periods), m.ZERO) self.headline.setText( f"Периодов: {len(periods)} · прибыль за все: {m.fmt_money(earned, currency)}" ) class ProductsTab(QWidget): """Что кормит, а что лежит мёртвым грузом.""" COLUMNS = [ "Товар", "Продано", "Ед.", "Выручка", "Себестоимость", "Наценка", "Наценка %", "Съедено", "На складе", "Склад по себестоимости", ] def __init__(self, ctx, parent=None): super().__init__(parent) self.ctx = ctx layout = QVBoxLayout(self) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(10) self.headline = w.label("", "h2") layout.addWidget(w.row(w.label("Отсортировано по заработку.", "dim"), None, self.headline)) self.chart = BarChart() layout.addWidget(self.chart) self.table = w.table(self.COLUMNS) # Порядок в таблице должен совпадать с графиком и с подписью над ним. # Без этого таблица молча пересортировывается по названию, и рядом # оказываются два разных ответа на один вопрос «кто больше приносит». self.table.sortByColumn(self.COLUMNS.index("Наценка"), Qt.DescendingOrder) layout.addWidget(self.table, 1) def refresh(self) -> None: rows_data = stats.build_products(self.ctx.vault.doc, self.ctx.report) currency = self.ctx.currency rows = [] for item in rows_data: rows.append( [ w.text_item(item.name), w.sortable_num_item(m.fmt_qty(item.sold_qty), item.sold_qty), w.text_item(item.unit), money_cell(item.revenue, currency), money_cell(item.cogs, currency), money_cell(item.margin, currency, signed_color(item.margin), dim_zero=False), w.sortable_num_item( f"{item.margin_pct}%" if item.revenue else "—", item.margin_pct, "" if item.revenue else theme.MUTED, ), money_cell(item.consumed_cost, currency, theme.DANGER if item.consumed_cost else ""), w.sortable_num_item(m.fmt_qty(item.stock_qty), item.stock_qty), money_cell(item.stock_cost, currency), ] ) w.fill(self.table, rows) self.chart.set_bars( [ Bar( label=item.name, value=item.margin, tooltip=f"{item.name}\nНаценка: {m.fmt_money(item.margin, currency)}\n" f"Продано: {m.fmt_qty(item.sold_qty)} {item.unit}", ) for item in rows_data[:CHART_LIMIT] ], currency, theme.OK, ) earned = sum((item.margin for item in rows_data), m.ZERO) self.headline.setText(f"Наценка по всем товарам: {m.fmt_money(earned, currency)}") class PeopleTab(QWidget): """Кто сколько взял и сколько остался должен.""" COLUMNS = ["Кто", "Покупок", "Выручка", "Оплачено", "Чаевые", "Долг"] def __init__(self, ctx, parent=None): super().__init__(parent) self.ctx = ctx layout = QVBoxLayout(self) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(10) self.headline = w.label("", "h2") layout.addWidget(w.row(w.label("Отсортировано по выручке.", "dim"), None, self.headline)) self.chart = BarChart() layout.addWidget(self.chart) self.table = w.table(self.COLUMNS) self.table.sortByColumn(self.COLUMNS.index("Выручка"), Qt.DescendingOrder) layout.addWidget(self.table, 1) def refresh(self) -> None: people = stats.build_people(self.ctx.vault.doc) currency = self.ctx.currency rows = [] for person in people: rows.append( [ w.text_item(person.name), w.sortable_num_item(str(person.sales_count), person.sales_count), money_cell(person.revenue, currency), money_cell(person.paid, currency), money_cell(person.tips, currency, theme.OK if person.tips else ""), money_cell(person.debt, currency, theme.WARN if person.debt else ""), ] ) w.fill(self.table, rows) self.chart.set_bars( [ Bar( label=person.name, value=person.revenue, tooltip=f"{person.name}\nВыручка: {m.fmt_money(person.revenue, currency)}\n" f"Долг: {m.fmt_money(person.debt, currency)}", ) for person in people[:CHART_LIMIT] ], currency, theme.ACCENT, ) owed = sum((person.debt for person in people), m.ZERO) self.headline.setText( f"Покупателей: {len(people)} · должны в сумме: {m.fmt_money(owed, currency)}" ) class StatsPage(QWidget): def __init__(self, ctx, parent=None): super().__init__(parent) self.ctx = ctx layout = QVBoxLayout(self) layout.setContentsMargins(20, 18, 20, 8) layout.setSpacing(10) layout.addWidget(w.heading("Статистика")) self.tabs = QTabWidget() self.sections = [ ("Периоды", PeriodsTab(ctx)), ("Недели", BucketTab(ctx, stats.GRAIN_WEEK)), ("Месяцы", BucketTab(ctx, stats.GRAIN_MONTH)), ("Годы", BucketTab(ctx, stats.GRAIN_YEAR)), ("Товары", ProductsTab(ctx)), ("Люди", PeopleTab(ctx)), ] for title, tab in self.sections: self.tabs.addTab(tab, title) # Пересчитывать все шесть разрезов на каждое изменение базы незачем: # видно всё равно один. self.tabs.currentChanged.connect(lambda _: self._refresh_current()) layout.addWidget(self.tabs, 1) def refresh(self) -> None: self._refresh_current() def _refresh_current(self) -> None: index = self.tabs.currentIndex() if 0 <= index < len(self.sections): self.sections[index][1].refresh()