food-market/app/ui/page_dashboard.py
Claude 582f041dba Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.

Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.

В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.

По съеденному и подаренному чаевых не бывает — поле отключается.

Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 00:56:26 +03:00

217 lines
8.6 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.

"""Сводка: успеваю ли я рассчитаться с пекарней."""
from __future__ import annotations
from PySide6.QtWidgets import (
QGridLayout,
QProgressBar,
QScrollArea,
QVBoxLayout,
QWidget,
)
from .. import ledger
from .. import money as m
from . import theme
from . import widgets as w
def _profit_hint(summary, currency: str) -> str:
"""Расшифровка прибыли. Чаевые упоминаются, только когда они есть."""
parts = [f"наценка {m.fmt_money(summary.gross_margin, currency)}"]
if summary.tips > 0:
parts.append(f"+ чаевые {m.fmt_money(summary.tips, currency)}")
parts.append(f" съедено {m.fmt_money(summary.consumed_cost, currency)}")
return " ".join(parts)
class DashboardPage(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
outer.addWidget(scroll)
self.body = QWidget()
self.layout = QVBoxLayout(self.body)
self.layout.setContentsMargins(20, 18, 20, 18)
self.layout.setSpacing(14)
scroll.setWidget(self.body)
def refresh(self) -> None:
# Сводка целиком собирается заново: цифр немного, а следить за тем,
# какая карточка устарела, вышло бы дороже, чем перерисовать всё.
while self.layout.count():
item = self.layout.takeAt(0)
widget = item.widget()
if widget is not None:
# setParent(None) обязателен: deleteLater откладывает удаление
# до следующего прохода цикла событий, и до тех пор старые
# карточки остаются детьми body.
widget.setParent(None)
widget.deleteLater()
report = self.ctx.report
summary = report.summary
currency = self.ctx.currency
self.layout.addWidget(w.heading("Сводка"))
if report.warnings:
self.layout.addWidget(w.warning_banner(report.warnings))
self.layout.addWidget(self._metrics(summary, currency))
if summary.next_due is not None:
self.layout.addWidget(self._deadline_card(summary.next_due, currency))
self.layout.addWidget(w.label("Открытые партии", "h2"))
self.layout.addWidget(self._open_batches(report, currency))
self.layout.addWidget(w.label("Остатки", "h2"))
self.layout.addWidget(self._stock(report, currency))
self.layout.addStretch(1)
def _metrics(self, summary, currency: str) -> QWidget:
holder = QWidget()
grid = QGridLayout(holder)
grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(10)
overdue_hint = (
f"просрочено партий: {summary.overdue_count}" if summary.overdue_count else "всё в срок"
)
cards = [
(
"Долг пекарне",
m.fmt_money(summary.owed_to_bakery, currency),
overdue_hint,
theme.DANGER if summary.overdue_count else theme.TEXT,
),
(
"Мне должны",
m.fmt_money(summary.receivable, currency),
"по неоплаченным продажам",
theme.WARN if summary.receivable > 0 else theme.TEXT,
),
(
"Прибыль",
m.fmt_money(summary.profit, currency),
_profit_hint(summary, currency),
theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT,
),
(
# Показываем деньги, а не количество: у сока литры, у сыра
# килограммы, у булок штуки — общая сумма штук бессмысленна.
# Разбивка по товарам есть ниже в «Остатках».
"Остаток на складе",
m.fmt_money(summary.stock_cost, currency),
f"позиций: {summary.stock_positions}" if summary.stock_positions else "пусто",
theme.TEXT,
),
]
for index, (title, value, hint, color) in enumerate(cards):
grid.addWidget(w.metric_card(title, value, hint, color), 0, index)
grid.setColumnStretch(index, 1)
return holder
def _deadline_card(self, report, currency: str) -> QWidget:
card = w.card()
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 14, 16, 14)
layout.setSpacing(8)
color = theme.STATUS_COLORS.get(report.status, theme.ACCENT)
when = (
f"осталось {report.days_left} дн."
if report.days_left >= 0
else f"просрочена на {-report.days_left} дн."
)
layout.addWidget(
w.label(
f"Ближайший срок — партия {report.label}, до "
f"{report.batch.due_date.strftime('%d.%m.%Y')} ({when})",
"h2",
color,
)
)
bar = QProgressBar()
bar.setRange(0, 100)
bar.setValue(int(min(100, max(0, report.coverage_pct))))
bar.setFormat(f"собрано {report.coverage_pct}% себестоимости")
bar.setStyleSheet(f"QProgressBar::chunk {{ background: {color}; }}")
layout.addWidget(bar)
layout.addWidget(
w.label(
f"Надо вернуть {m.fmt_money(report.remaining_to_bakery, currency)}"
f"собрано деньгами {m.fmt_money(report.cash_collected, currency)}"
f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}",
"dim",
)
)
return card
def _open_batches(self, report, currency: str) -> QWidget:
table = w.table(
["", "Дата", "Покрыть до", "Осталось", "Долг", "Собрано", "Покрытие", "Статус"],
stretch_column=7,
)
table.setMaximumHeight(220)
rows = []
for item in sorted(report.open_batches, key=lambda r: r.batch.due_date):
color = theme.STATUS_COLORS.get(item.status, "")
days = (
f"{item.days_left} дн."
if item.days_left >= 0
else f"просрочено на {-item.days_left} дн."
)
rows.append(
[
w.sortable_num_item(str(item.batch.number), item.batch.number),
w.text_item(item.batch.date.strftime("%d.%m.%Y")),
w.text_item(item.batch.due_date.strftime("%d.%m.%Y")),
w.text_item(days, color),
w.sortable_num_item(
m.fmt_money(item.remaining_to_bakery, currency), item.remaining_to_bakery
),
w.sortable_num_item(m.fmt_money(item.cash_collected, currency), item.cash_collected),
w.sortable_num_item(f"{item.coverage_pct}%", item.coverage_pct, color),
w.text_item(ledger.STATUS_LABELS.get(item.status, ""), color),
]
)
w.fill(table, rows)
if not rows:
return w.label("Открытых партий нет — пекарня может печь дальше.", "dim")
return table
def _stock(self, report, currency: str) -> QWidget:
rows = [row for row in report.stock if row.qty > 0]
if not rows:
return w.label("Булок не осталось.", "dim")
table = w.table(["Товар", "Остаток", "Ед.", "По себестоимости"])
table.setMaximumHeight(220)
w.fill(
table,
[
[
w.text_item(row.name),
w.sortable_num_item(m.fmt_qty(row.qty), row.qty),
w.text_item(row.unit),
w.sortable_num_item(m.fmt_money(row.cost, currency), row.cost),
]
for row in rows
],
)
return table