food-market/app/ui/page_dashboard.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

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

206 lines
7.9 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_qty(summary.stock_qty),
f"по себестоимости {m.fmt_money(summary.stock_cost, currency)}",
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