food-market/app/ui/page_stats.py
Claude 26b401e507 Цена по фасовке не подставлялась после удаления строки
Обработчики строк запоминали номер строки в момент создания. После
удаления любой строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или за пределы таблицы. Строка молча
переставала работать целиком: выбираешь фасовку — цена не меняется,
меняешь товар — не пересобирается список фасовок. Приходилось править
цену руками.

Теперь обработчики привязаны к самому виджету, а строка ищется по нему
в момент вызова. То же исправлено в быстром вводе, где строк больше
и удаляют их чаще.

Заодно найдена мина, которую посадил я сам в разделе статистики:
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 17:50:42 +03:00

412 lines
17 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: у 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),
]
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.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()