Количества и раньше хранились в Decimal с тремя знаками, но до документа они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча не появлялся, и выглядело это так, будто дробные вводить нельзя вообще. Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже. Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось общее количество остатка по всем товарам, то есть литры с килограммами и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка по единицам осталась в таблице «Остатки». Фасовки тоже могут быть дробными: бутылка 1,5 л. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.3 KiB
Python
209 lines
8.3 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
|
||
|
||
|
||
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),
|
||
f"наценка {m.fmt_money(summary.gross_margin, currency)} "
|
||
f"− съедено {m.fmt_money(summary.consumed_cost, 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
|