food-market/app/ui/page_stats.py
Claude 38e9614a25 Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.

Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.

Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.

График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.

Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:33:11 +03:00

409 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Статистика: периоды, недели, месяцы, годы, товары и люди.
Экран намеренно ничего не считает сам — все цифры приходят из 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),
]
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 = QComboBox()
for title, attribute, color in METRICS:
self.metric.addItem(title, (attribute, color))
self.metric.currentIndexChanged.connect(self._draw_chart)
self.headline = w.label("", "h2")
layout.addWidget(w.row(w.label("На графике:"), self.metric, 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),
]
def _draw_chart(self) -> None:
attribute, color = self.metric.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.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.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.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()