food-market/app/ui/page_dashboard.py

218 lines
8.8 KiB
Python
Raw Normal View History

"""Сводка: успеваю ли я рассчитаться с пекарней."""
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)
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.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 21:50:42 +07:00
# Не self.layout: имя занято методом QWidget.layout().
self.body_layout = QVBoxLayout(self.body)
self.body_layout.setContentsMargins(20, 18, 20, 18)
self.body_layout.setSpacing(14)
scroll.setWidget(self.body)
def refresh(self) -> None:
# Сводка целиком собирается заново: цифр немного, а следить за тем,
# какая карточка устарела, вышло бы дороже, чем перерисовать всё.
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 21:50:42 +07:00
while self.body_layout.count():
item = self.body_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.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 21:50:42 +07:00
self.body_layout.addWidget(w.heading("Сводка"))
if report.warnings:
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 21:50:42 +07:00
self.body_layout.addWidget(w.warning_banner(report.warnings))
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 21:50:42 +07:00
self.body_layout.addWidget(self._metrics(summary, currency))
if summary.next_due is not None:
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 21:50:42 +07:00
self.body_layout.addWidget(self._deadline_card(summary.next_due, currency))
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 21:50:42 +07:00
self.body_layout.addWidget(w.label("Открытые партии", "h2"))
self.body_layout.addWidget(self._open_batches(report, currency))
Цена по фасовке не подставлялась после удаления строки Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: 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 21:50:42 +07:00
self.body_layout.addWidget(w.label("Остатки", "h2"))
self.body_layout.addWidget(self._stock(report, currency))
self.body_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),
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