2026-08-22 21:29:53 +07:00
|
|
|
|
"""Сводка: успеваю ли я рассчитаться с пекарней."""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
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),
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
_profit_hint(summary, currency),
|
2026-08-22 21:29:53 +07:00
|
|
|
|
theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.
Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».
Фасовки тоже могут быть дробными: бутылка 1,5 л.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:20:29 +07:00
|
|
|
|
# Показываем деньги, а не количество: у сока литры, у сыра
|
|
|
|
|
|
# килограммы, у булок штуки — общая сумма штук бессмысленна.
|
|
|
|
|
|
# Разбивка по товарам есть ниже в «Остатках».
|
2026-08-22 21:29:53 +07:00
|
|
|
|
"Остаток на складе",
|
Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.
Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».
Фасовки тоже могут быть дробными: бутылка 1,5 л.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:20:29 +07:00
|
|
|
|
m.fmt_money(summary.stock_cost, currency),
|
|
|
|
|
|
f"позиций: {summary.stock_positions}" if summary.stock_positions else "пусто",
|
2026-08-22 21:29:53 +07:00
|
|
|
|
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
|