food-market/app/ui/page_dashboard.py
Claude 1a97fee872 Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.

Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».

Фасовки тоже могут быть дробными: бутылка 1,5 л.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:20:29 +03:00

209 lines
8.3 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
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