food-market/app/ui/page_batches.py
Claude efce552e2b Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.

1. База и код разъехались по разным репозиториям. Автокоммит и раньше
   трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
   поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
   клон food-records со своим .git, а исходники его игнорируют целиком.
   При первом запуске приложение встаёт на уже существующую историю
   сервера, а не заводит параллельную.

2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
   за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
   считаются в базовых единицах, поэтому поштучные продажи вычитаются из
   купленных пачек. Размер фасовки хранится в документе слепком.
   Миграция схемы 1→2 не меняет поведение уже заведённых данных.

3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
   отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
   заводится как контрагент, повтор периода подсвечивается.

Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.

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

364 lines
13 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 datetime import timedelta
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QFormLayout,
QHBoxLayout,
QLineEdit,
QMessageBox,
QProgressBar,
QSplitter,
QVBoxLayout,
QWidget,
)
from .. import journal, ledger
from .. import money as m
from . import theme
from . import widgets as w
class BatchDialog(QDialog):
"""Форма партии: что взял, почём и до какого числа надо рассчитаться."""
def __init__(self, ctx, batch=None, parent=None):
super().__init__(parent)
self.ctx = ctx
self.batch = batch
self.setWindowTitle(f"Закупка №{batch.number}" if batch else "Новая закупка")
self.setMinimumSize(640, 480)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
form = QFormLayout()
form.setSpacing(8)
self.date = w.DateInput(batch.date if batch else None)
self.date.dateChanged.connect(self._shift_due_date)
form.addRow("Дата закупки", self.date)
self.due = w.DateInput(
batch.due_date
if batch
else self.date.get_date() + timedelta(days=ctx.vault.doc.settings.default_period_days)
)
form.addRow("Покрыть до", self.due)
self.note = QLineEdit(batch.note if batch else "")
self.note.setPlaceholderText("договорились по телефону")
form.addRow("Заметка", self.note)
layout.addLayout(form)
layout.addWidget(w.label("Что взял", "h2"))
products = [p for p in ctx.vault.doc.products if not p.archived]
self.lines = w.LinesEditor(
products, "Себестоимость", lambda p, uom: p.cost_for(uom), ctx.currency
)
if batch:
self.lines.set_lines(batch.lines, "unit_cost")
elif products:
self.lines.add_line()
layout.addWidget(self.lines, 1)
if not products:
layout.addWidget(
w.label("Сначала заведи товары на вкладке «Товары».", "dim", theme.WARN)
)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept))
)
def _shift_due_date(self) -> None:
"""При смене даты закупки двигаем дедлайн, сохраняя длину периода."""
if self.batch is not None:
return
days = self.ctx.vault.doc.settings.default_period_days
self.due.set_date(self.date.get_date() + timedelta(days=days))
class PaymentDialog(QDialog):
"""Возврат денег пекарне."""
def __init__(self, ctx, batch=None, suggested=None, parent=None):
super().__init__(parent)
self.setWindowTitle("Платёж пекарне")
self.setMinimumWidth(380)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
form = QFormLayout()
form.setSpacing(8)
self.date = w.DateInput()
form.addRow("Дата", self.date)
self.amount = w.MoneySpin(ctx.currency)
if suggested is not None:
self.amount.set_decimal(suggested)
form.addRow("Сумма", self.amount)
self.note = QLineEdit()
form.addRow("Заметка", self.note)
layout.addLayout(form)
if batch is not None:
layout.addWidget(w.label(f"Зачтётся в партию №{batch.number}.", "dim"))
else:
layout.addWidget(
w.label("Без выбранной партии платёж закроет самые старые долги.", "dim")
)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Записать", "primary", self.accept))
)
class BatchesPage(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(12)
layout.addWidget(w.heading("Закупки"))
layout.addWidget(
w.row(
w.button("+ Закупка", "primary", self.create),
w.button("Изменить", on_click=self.edit),
w.button("Платёж пекарне", on_click=self.pay),
w.button("Закрыть / открыть", on_click=self.toggle_closed),
w.button("Удалить", "danger", self.delete),
)
)
splitter = QSplitter(Qt.Vertical)
self.table = w.table(
["", "Дата", "Покрыть до", "Осталось", "Долг", "Отдано", "Собрано", "Покрытие", "Статус"],
stretch_column=8,
)
self.table.itemSelectionChanged.connect(self._show_details)
self.table.doubleClicked.connect(self.edit)
splitter.addWidget(self.table)
self.details = self._build_details()
splitter.addWidget(self.details)
splitter.setSizes([420, 260])
layout.addWidget(splitter, 1)
def _build_details(self) -> QWidget:
holder = QWidget()
layout = QVBoxLayout(holder)
layout.setContentsMargins(0, 8, 0, 0)
layout.setSpacing(8)
self.details_title = w.label("Выбери партию", "h2")
layout.addWidget(self.details_title)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
layout.addWidget(self.progress)
self.details_line = w.label("", "dim")
self.details_line.setWordWrap(True)
layout.addWidget(self.details_line)
self.contents = w.table(["Товар", "Взято", "Осталось", "Себестоимость", "Сумма"])
layout.addWidget(self.contents, 1)
return holder
# --- отрисовка ---
def refresh(self) -> None:
currency = self.ctx.currency
rows, keys = [], []
# Свежие партии сверху: они и есть текущая работа.
for report in sorted(
self.ctx.report.batches, key=lambda r: (r.batch.date, r.batch.number), reverse=True
):
batch = report.batch
color = theme.STATUS_COLORS.get(report.status, "")
days = (
""
if report.status == ledger.STATUS_SETTLED
else f"{report.days_left} дн."
if report.days_left >= 0
else f"просрочено на {-report.days_left} дн."
)
rows.append(
[
w.sortable_num_item(str(batch.number), batch.number),
w.text_item(batch.date.strftime("%d.%m.%Y")),
w.text_item(batch.due_date.strftime("%d.%m.%Y")),
w.text_item(days, color),
w.sortable_num_item(m.fmt_money(report.cost_total, currency), report.cost_total),
w.sortable_num_item(m.fmt_money(report.paid_to_bakery, currency), report.paid_to_bakery),
w.sortable_num_item(m.fmt_money(report.cash_collected, currency), report.cash_collected),
w.sortable_num_item(f"{report.coverage_pct}%", report.coverage_pct, color),
w.text_item(ledger.STATUS_LABELS.get(report.status, ""), color),
]
)
keys.append(batch.id)
w.fill(self.table, rows, keys)
self._show_details()
def _show_details(self) -> None:
report = self._selected_report(quiet=True)
if report is None:
self.details_title.setText("Выбери партию")
self.details_line.setText("")
self.progress.setValue(0)
self.contents.setRowCount(0)
return
currency = self.ctx.currency
color = theme.STATUS_COLORS.get(report.status, theme.ACCENT)
self.details_title.setText(f"Партия {report.label}")
self.progress.setValue(int(min(100, max(0, report.coverage_pct))))
self.progress.setFormat(f"собрано {report.coverage_pct}% себестоимости")
self.progress.setStyleSheet(f"QProgressBar::chunk {{ background: {color}; }}")
parts = [
f"Долг пекарне: {m.fmt_money(report.remaining_to_bakery, currency)} "
f"из {m.fmt_money(report.cost_total, currency)}",
f"собрано деньгами {m.fmt_money(report.cash_collected, currency)}",
f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}",
]
if report.consumed_cost > 0:
parts.append(f"съедено и подарено на {m.fmt_money(report.consumed_cost, currency)}")
self.details_line.setText("".join(parts))
doc = self.ctx.vault.doc
rows = []
for line in report.batch.lines:
left = report.stock.get(line.product_id, m.ZERO)
rows.append(
[
w.text_item(doc.product_name(line.product_id)),
w.sortable_num_item(m.fmt_qty(line.qty), line.qty),
w.sortable_num_item(
m.fmt_qty(left), left, theme.MUTED if left <= 0 else ""
),
w.sortable_num_item(m.fmt_money(line.unit_cost, currency), line.unit_cost),
w.sortable_num_item(m.fmt_money(line.total, currency), line.total),
]
)
w.fill(self.contents, rows)
# --- действия ---
def _selected_report(self, quiet: bool = False):
batch_id = w.selected_key(self.table)
report = self.ctx.report.batch_report(batch_id) if batch_id else None
if report is None and not quiet:
QMessageBox.information(self, "Не выбрано", "Выбери партию в списке.")
return report
def create(self) -> None:
if not [p for p in self.ctx.vault.doc.products if not p.archived]:
QMessageBox.information(
self, "Нет товаров", "Сначала заведи булки на вкладке «Товары»."
)
return
dialog = BatchDialog(self.ctx, parent=self)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.create_batch(
self.ctx.vault,
dialog.date.get_date(),
dialog.due.get_date(),
dialog.lines.lines("unit_cost"),
dialog.note.text(),
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def edit(self) -> None:
report = self._selected_report()
if report is None:
return
dialog = BatchDialog(self.ctx, report.batch, parent=self)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.update_batch(
self.ctx.vault,
report.batch.id,
dialog.date.get_date(),
dialog.due.get_date(),
dialog.lines.lines("unit_cost"),
dialog.note.text(),
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def pay(self) -> None:
report = self._selected_report(quiet=True)
batch = report.batch if report else None
dialog = PaymentDialog(
self.ctx, batch, report.remaining_to_bakery if report else None, parent=self
)
if dialog.exec() != QDialog.Accepted:
return
try:
journal.add_bakery_payment(
self.ctx.vault,
dialog.amount.value_decimal(),
dialog.date.get_date(),
batch.id if batch else None,
dialog.note.text(),
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()
def toggle_closed(self) -> None:
report = self._selected_report()
if report is None:
return
journal.set_batch_closed(self.ctx.vault, report.batch.id, not report.batch.closed)
self.ctx.changed()
def delete(self) -> None:
report = self._selected_report()
if report is None:
return
confirm = QMessageBox.question(
self,
"Удалить партию?",
f"Удалить партию {report.label}?\n\n"
"Платежи пекарне останутся в учёте — они просто потеряют привязку.",
)
if confirm != QMessageBox.Yes:
return
try:
journal.delete_batch(self.ctx.vault, report.batch.id)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return
self.ctx.changed()