Обработчики строк запоминали номер строки в момент создания. После удаления любой строки всё, что было ниже, съезжает вверх, и запомненный номер начинает указывать на соседа или за пределы таблицы. Строка молча переставала работать целиком: выбираешь фасовку — цена не меняется, меняешь товар — не пересобирается список фасовок. Приходилось править цену руками. Теперь обработчики привязаны к самому виджету, а строка ищется по нему в момент вызова. То же исправлено в быстром вводе, где строк больше и удаляют их чаще. Заодно найдена мина, которую посадил я сам в разделе статистики: self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(), который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным «object is not callable». Такая же история была с self.layout в сводке. Оба переименованы, добавлен тест, который обходит все виджеты и проверяет, что ни один атрибут не затеняет метод Qt. И подсказка в карточке товара: себестоимость фасовки нигде не вводится, она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо написано, какая сумма подставится в закупку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
"""Сводка: успеваю ли я рассчитаться с пекарней."""
|
||
|
||
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: имя занято методом 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:
|
||
# Сводка целиком собирается заново: цифр немного, а следить за тем,
|
||
# какая карточка устарела, вышло бы дороже, чем перерисовать всё.
|
||
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.body_layout.addWidget(w.heading("Сводка"))
|
||
|
||
if report.warnings:
|
||
self.body_layout.addWidget(w.warning_banner(report.warnings))
|
||
|
||
self.body_layout.addWidget(self._metrics(summary, currency))
|
||
|
||
if summary.next_due is not None:
|
||
self.body_layout.addWidget(self._deadline_card(summary.next_due, currency))
|
||
|
||
self.body_layout.addWidget(w.label("Открытые партии", "h2"))
|
||
self.body_layout.addWidget(self._open_batches(report, currency))
|
||
|
||
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),
|
||
_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
|