food-market/app/ui/page_stats.py

412 lines
17 KiB
Python
Raw Normal View History

Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
"""Статистика: периоды, недели, месяцы, годы, товары и люди.
Экран намеренно ничего не считает сам все цифры приходят из 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() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:50:42 +07:00
# Не self.metric: у QWidget есть метод metric(), который Qt зовёт
# при смене стиля. Атрибут его затеняет, и приложение падает
# с невнятным «object is not callable».
self.metric_box = QComboBox()
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
for title, attribute, color in METRICS:
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:50:42 +07:00
self.metric_box.addItem(title, (attribute, color))
self.metric_box.currentIndexChanged.connect(self._draw_chart)
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
self.headline = w.label("", "h2")
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:50:42 +07:00
layout.addWidget(w.row(w.label("На графике:"), self.metric_box, None, self.headline))
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
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:
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:50:42 +07:00
attribute, color = self.metric_box.currentData()
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
shown = self.buckets[-CHART_LIMIT:]
currency = self.ctx.currency
self.chart.set_bars(
[
Bar(
label=self._short(bucket),
value=getattr(bucket, attribute),
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:50:42 +07:00
tooltip=f"{bucket.label}\n{self.metric_box.currentText()}: "
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
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(
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:50:42 +07:00
f"{self.metric_box.currentText()} за всё время: {m.fmt_money(total, currency)}{tail}"
Раздел статистики Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
)
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()