Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «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()
|