food-market/app/ui/page_stats.py
Claude 3968806094 Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.

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

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

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

Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:44:19 +03:00

417 lines
17 KiB
Python
Raw Permalink 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),
("Прощено пекарней", "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()