Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт, но денег никто не отдавал. У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы суммы, которых не платил. Поэтому «Отдано» — только живые деньги, «Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике по периодам и по времени. В прибыль прощённое идёт целиком: эти деньги предназначались пекарне, а остались у тебя. «Осталось собрать» на ту же сумму уменьшается — собирать под прощённый остаток уже не надо. Простить больше долга нельзя: лишнее не засчитывается и, в отличие от переплаты деньгами, никуда не переливается. В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь сколько отдал, остаток уходит отдельной записью, партия закрывается сама, потому что её долг становится нулём. Миграция схемы 3→4 помечает всё записанное раньше как живые деньги. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
417 lines
17 KiB
Python
417 lines
17 KiB
Python
"""Статистика: периоды, недели, месяцы, годы, товары и люди.
|
||
|
||
Экран намеренно ничего не считает сам — все цифры приходят из 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()
|