Пекарня может простить остаток

Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.

У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.

В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.

В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.

Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-24 02:44:19 +03:00
parent 26b401e507
commit 3968806094
12 changed files with 523 additions and 52 deletions

View File

@ -58,7 +58,8 @@
- по дате **платежа** — колонка «Собрано». Это «сколько денег реально пришло»,
и долг, возвращённый в сентябре, попадёт в сентябрь, а не в августовскую
продажу;
- по дате **закупки** — «Закуплено», по дате платежа пекарне — «Отдано пекарне».
- по дате **закупки** — «Закуплено», по дате расчёта с пекарней — «Отдано
пекарне» и «Прощено».
У разреза «Периоды» основание третье: там всё привязано к самой партии, потому
что вопрос стоит иначе — «покрыл ли я эту партию к сроку».
@ -72,6 +73,28 @@
корзин больше 24, на графике показываются последние, и об этом написано прямо
над ним — молча показанная часть выглядела бы как всё.
### Пекарня простила остаток
Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500,
взяли 5 000, полтысячи оставили. Долг при этом закрыт, но денег никто не
отдавал.
В диалоге «Платёж пекарне» есть галочка **«Остаток простили — закрыть
партию»**: вводишь, сколько реально отдал, и остаток уходит отдельной записью
вида «скидка пекарни». Партия закрывается сама — её долг становится нулём.
Скидка хранится отдельно от платежей намеренно. Свали их в одну кучу — и
«Отдано пекарне» в статистике покажет суммы, которых ты не платил. Поэтому:
- **«Отдано»** — только живые деньги;
- **«Прощено»** — закрывает долг, но кармана не покидает;
- в прибыль прощённое идёт **целиком**: эти деньги предназначались пекарне,
а остались у тебя;
- «осталось собрать» уменьшается — под прощённый остаток собирать уже не надо.
Простить больше, чем должен, нельзя: лишнее не засчитывается и никуда не
переливается.
### Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
@ -137,9 +160,9 @@
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
деньги за какую партию уже пришли.
Прибыль считается как наценка с проданного **плюс** чаевые **минус**
себестоимость съеденного и подаренного: за съеденное пекарне платить всё
равно, и покрывается это из маржи.
Прибыль считается как наценка с проданного **плюс** чаевые **плюс**
прощённое пекарней **минус** себестоимость съеденного и подаренного:
за съеденное пекарне платить всё равно, и покрывается это из маржи.
## Как запустить

View File

@ -23,6 +23,9 @@ from decimal import Decimal
from . import money as m
from .models import (
BAKERY_CASH,
BAKERY_DISCOUNT,
BAKERY_KIND_LABELS,
ONE,
SALE_KIND_LABELS,
BakeryPayment,
@ -820,19 +823,28 @@ def add_bakery_payment(
on_date: date | None = None,
batch_id: str | None = None,
note: str = "",
kind: str = BAKERY_CASH,
) -> BakeryPayment:
"""Записать расчёт с пекарней: отданные деньги либо прощённый остаток."""
doc = vault.doc
amount = m.money(amount)
if amount <= 0:
raise ValidationError("Сумма платежа должна быть больше нуля.")
raise ValidationError("Сумма должна быть больше нуля.")
if batch_id and doc.batch(batch_id) is None:
raise ValidationError("Партия не найдена.")
if kind not in (BAKERY_CASH, BAKERY_DISCOUNT):
raise ValidationError(f"Неизвестный вид расчёта: {kind}.")
if kind == BAKERY_DISCOUNT and not batch_id:
# Прощают конкретный остаток по конкретной партии. Без привязки
# непонятно, чей долг закрылся.
raise ValidationError("Скидку нужно привязать к партии.")
payment = BakeryPayment(
id=new_id("bp"),
date=parse_date(on_date) if on_date else date.today(),
amount=amount,
batch_id=batch_id or None,
kind=kind,
note=note.strip(),
)
doc.bakery_payments.append(payment)
@ -840,12 +852,13 @@ def add_bakery_payment(
batch = doc.batch(batch_id) if batch_id else None
_record(
vault,
"bakery_payment.add",
"bakery_payment.discount" if payment.is_discount else "bakery_payment.add",
"bakery_payment",
payment.id,
f"Платёж пекарне {m.fmt_money(amount, doc.settings.currency)}",
f"{BAKERY_KIND_LABELS[kind]} {m.fmt_money(amount, doc.settings.currency)}",
[
Change("дата", "", dump_date(payment.date)),
Change("вид", "", BAKERY_KIND_LABELS[kind]),
Change("сумма", "", m.fmt_money(amount, doc.settings.currency)),
Change("партия", "", _batch_label(batch) if batch else "без привязки"),
Change("заметка", "", payment.note),
@ -854,6 +867,18 @@ def add_bakery_payment(
return payment
def write_off_batch(vault, batch_id: str, amount, on_date: date | None = None, note: str = ""):
"""Списать остаток по партии как скидку пекарни.
Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать
5 500, взяли 5 000, полтысячи оставили. Долг при этом закрыт, но денег
никто не отдавал поэтому запись отдельного вида, а не платёж.
"""
return add_bakery_payment(
vault, amount, on_date, batch_id, note, kind=BAKERY_DISCOUNT
)
def delete_bakery_payment(vault, payment_id: str) -> None:
doc = vault.doc
payment = next((p for p in doc.bakery_payments if p.id == payment_id), None)
@ -866,9 +891,11 @@ def delete_bakery_payment(vault, payment_id: str) -> None:
"bakery_payment.delete",
"bakery_payment",
payment_id,
f"Платёж пекарне {m.fmt_money(payment.amount, doc.settings.currency)}",
f"{BAKERY_KIND_LABELS.get(payment.kind, 'Платёж')} "
f"{m.fmt_money(payment.amount, doc.settings.currency)}",
[
Change("дата", dump_date(payment.date), ""),
Change("вид", BAKERY_KIND_LABELS.get(payment.kind, payment.kind), ""),
Change("сумма", m.fmt_money(payment.amount, doc.settings.currency), ""),
],
)

View File

@ -98,12 +98,20 @@ class BatchReport:
paid_to_bakery: Decimal
days_left: int
status: str
# Сколько пекарня простила по этой партии: долг закрыт, а деньги остались
# у тебя.
discount: Decimal = m.ZERO
stock: dict[str, Decimal] = field(default_factory=dict)
stock_cost: Decimal = m.ZERO
@property
def settled_amount(self) -> Decimal:
"""Чем закрыт долг: деньгами и прощённым остатком вместе."""
return m.money(self.paid_to_bakery + self.discount)
@property
def remaining_to_bakery(self) -> Decimal:
return max(m.ZERO, m.money(self.cost_total - self.paid_to_bakery))
return max(m.ZERO, m.money(self.cost_total - self.settled_amount))
@property
def coverage_pct(self) -> Decimal:
@ -114,8 +122,11 @@ class BatchReport:
@property
def still_to_collect(self) -> Decimal:
"""Сколько денег ещё надо собрать, чтобы выйти в ноль по этой партии."""
return max(m.ZERO, m.money(self.cost_total - self.cash_collected))
"""Сколько денег ещё надо собрать, чтобы выйти в ноль по этой партии.
Прощённый остаток вычитается: собирать под него уже не нужно.
"""
return max(m.ZERO, m.money(self.cost_total - self.discount - self.cash_collected))
@property
def label(self) -> str:
@ -147,6 +158,7 @@ class Summary:
cash_collected: Decimal
gross_margin: Decimal
tips: Decimal
bakery_discounts: Decimal
consumed_cost: Decimal
# Осторожно: сумма по всем товарам сразу. Осмысленна, только когда единица
# измерения у всех одна. Литры с килограммами складывать нельзя, поэтому
@ -164,8 +176,13 @@ class Summary:
Съеденное и подаренное вычитается: пекарне за эти булки всё равно
платить, и покрывается это из наценки на проданных. Чаевые
прибавляются целиком себестоимости за ними нет.
Прощённый пекарней остаток тоже доход: эти деньги предназначались
ей, но остались у тебя.
"""
return m.money(self.gross_margin + self.tips - self.consumed_cost)
return m.money(
self.gross_margin + self.tips + self.bakery_discounts - self.consumed_cost
)
@dataclass
@ -325,35 +342,51 @@ def _spread_tip(tip: Decimal, allocations: list[Allocation]) -> None:
# --- Разнесение платежей пекарне ------------------------------------------
def _spread_bakery_payments(doc: Document, cost_totals: dict[str, Decimal]) -> tuple[dict[str, Decimal], Decimal]:
"""Разложить платежи пекарне по партиям.
def _spread_bakery_payments(
doc: Document, cost_totals: dict[str, Decimal]
) -> tuple[dict[str, Decimal], dict[str, Decimal], Decimal]:
"""Разложить расчёты с пекарней по партиям.
Платёж с указанной партией гасит её; излишек не превращается в
Возвращает отдельно отданные деньги, отдельно прощённые остатки и
переплату. Долг гасят и те и другие, но в отчётах это разные вещи:
скидка закрывает партию, не покидая кармана.
Расчёт с указанной партией гасит её; излишек не превращается в
отрицательный долг, а переливается в общий котёл. Платежи без привязки
гасят старейшие непокрытые партии так же, как это происходит в жизни.
"""
paid: dict[str, Decimal] = {batch_id: m.ZERO for batch_id in cost_totals}
discount: dict[str, Decimal] = {batch_id: m.ZERO for batch_id in cost_totals}
pool = m.ZERO
def covered(batch_id: str) -> Decimal:
return paid.get(batch_id, m.ZERO) + discount.get(batch_id, m.ZERO)
for payment in doc.bakery_payments:
target = discount if payment.is_discount else paid
batch_id = payment.batch_id
if batch_id and batch_id in paid:
room = max(m.ZERO, cost_totals[batch_id] - paid[batch_id])
if batch_id and batch_id in cost_totals:
room = max(m.ZERO, cost_totals[batch_id] - covered(batch_id))
applied = min(room, payment.amount)
paid[batch_id] += applied
pool += payment.amount - applied
target[batch_id] += applied
# Прощённый остаток сверх долга ничего не гасит и никуда не
# переливается: списывать нечего.
if not payment.is_discount:
pool += payment.amount - applied
elif payment.is_discount:
continue
else:
pool += payment.amount
for _, batch in _ordered_batches(doc):
if pool <= 0:
break
room = max(m.ZERO, cost_totals.get(batch.id, m.ZERO) - paid.get(batch.id, m.ZERO))
room = max(m.ZERO, cost_totals.get(batch.id, m.ZERO) - covered(batch.id))
applied = min(room, pool)
paid[batch.id] = paid.get(batch.id, m.ZERO) + applied
pool -= applied
return paid, pool
return paid, discount, pool
# --- Сборка отчёта --------------------------------------------------------
@ -376,7 +409,7 @@ def build(doc: Document, today: date | None = None) -> Report:
unit_costs = batch_unit_costs(doc)
cost_totals = {batch.id: batch.cost_total for batch in doc.batches}
paid_to_bakery, unassigned = _spread_bakery_payments(doc, cost_totals)
paid_to_bakery, discounts, unassigned = _spread_bakery_payments(doc, cost_totals)
per_batch: dict[str, dict[str, Decimal]] = {
batch.id: {"qty_sold": m.ZERO, "revenue": m.ZERO, "cash": m.ZERO, "consumed": m.ZERO}
@ -398,6 +431,7 @@ def build(doc: Document, today: date | None = None) -> Report:
bucket = per_batch[batch.id]
cost_total = cost_totals[batch.id]
paid = m.money(paid_to_bakery.get(batch.id, m.ZERO))
discount = m.money(discounts.get(batch.id, m.ZERO))
days_left = (batch.due_date - today).days
stock = {
pid: qty
@ -415,8 +449,9 @@ def build(doc: Document, today: date | None = None) -> Report:
cash_collected=m.money(bucket["cash"]),
consumed_cost=m.money(bucket["consumed"]),
paid_to_bakery=paid,
discount=discount,
days_left=days_left,
status=_status(batch, max(m.ZERO, cost_total - paid), days_left),
status=_status(batch, max(m.ZERO, cost_total - paid - discount), days_left),
stock=stock,
stock_cost=m.money(
sum(
@ -521,6 +556,9 @@ def _summary(
# Считаем по продажам, а не по аллокациям: если товара не хватило и
# часть продажи никуда не легла, чаевые всё равно получены полностью.
tips=m.money(sum((s.tip for s in doc.sales), m.ZERO)),
# Считаем по отчётам партий, а не по записям: прощённое сверх долга
# ничего не закрывает и доходом не является.
bakery_discounts=m.money(sum((r.discount for r in batch_reports), m.ZERO)),
consumed_cost=m.money(consumed),
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)),
stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)),

View File

@ -15,7 +15,7 @@ from typing import Any
from . import money as m
SCHEMA_VERSION = 3
SCHEMA_VERSION = 4
ONE = Decimal(1)
@ -465,9 +465,27 @@ class Sale:
}
BAKERY_CASH = "cash"
BAKERY_DISCOUNT = "discount"
BAKERY_KIND_LABELS = {
BAKERY_CASH: "Платёж",
BAKERY_DISCOUNT: "Скидка пекарни",
}
@dataclass
class BakeryPayment:
"""Возврат денег пекарне.
"""Расчёт с пекарней.
Два вида, и различать их обязательно:
* `cash` деньги реально ушли из кармана;
* `discount` пекарня простила остаток. Долг закрыт, но денег никто не
отдавал, и для тебя это доход.
Свалить их в одну кучу нельзя: тогда «отдано пекарне» в статистике
показывало бы суммы, которых ты не платил.
batch_id=None означает «просто отдал денег» такой платёж гасит долг
по старейшим непокрытым партиям (см. ledger).
@ -477,8 +495,13 @@ class BakeryPayment:
date: date
amount: Decimal
batch_id: str | None = None
kind: str = BAKERY_CASH
note: str = ""
@property
def is_discount(self) -> bool:
return self.kind == BAKERY_DISCOUNT
@classmethod
def from_dict(cls, d: dict) -> "BakeryPayment":
return cls(
@ -486,6 +509,7 @@ class BakeryPayment:
date=parse_date(d["date"]),
amount=m.money(d.get("amount")),
batch_id=d.get("batch_id") or None,
kind=d.get("kind", BAKERY_CASH),
note=d.get("note", ""),
)
@ -495,6 +519,7 @@ class BakeryPayment:
"date": dump_date(self.date),
"amount": m.dumps(self.amount),
"batch_id": self.batch_id,
"kind": self.kind,
"note": self.note,
}

View File

@ -139,11 +139,14 @@ class Bucket:
# По датам закупки и расчёта с пекарней.
purchased: Decimal = m.ZERO
paid_to_bakery: Decimal = m.ZERO
# Прощённый пекарней остаток. Отдельно от paid_to_bakery: долг закрыт,
# но денег не отдавали.
bakery_discount: Decimal = m.ZERO
@property
def profit(self) -> Decimal:
"""Наценка плюс чаевые минус себестоимость съеденного и подаренного."""
return m.money(self.margin + self.tips - self.consumed_cost)
"""Наценка, чаевые и прощённое пекарней минус съеденное и подаренное."""
return m.money(self.margin + self.tips + self.bakery_discount - self.consumed_cost)
@property
def margin_pct(self) -> Decimal:
@ -209,7 +212,11 @@ def build_buckets(doc: Document, report: ledger.Report, grain: str) -> list[Buck
bucket_for(batch.date).purchased += batch.cost_total
for payment in doc.bakery_payments:
bucket_for(payment.date).paid_to_bakery += payment.amount
bucket = bucket_for(payment.date)
if payment.is_discount:
bucket.bakery_discount += payment.amount
else:
bucket.paid_to_bakery += payment.amount
return [_rounded(b) for b in sorted(buckets.values(), key=lambda b: b.start)]
@ -217,7 +224,7 @@ def build_buckets(doc: Document, report: ledger.Report, grain: str) -> list[Buck
def _rounded(bucket: Bucket) -> Bucket:
"""Округлить до копеек один раз в конце, а не на каждом слагаемом."""
for name in ("revenue", "cogs", "margin", "tips", "consumed_cost",
"collected", "purchased", "paid_to_bakery"):
"collected", "purchased", "paid_to_bakery", "bakery_discount"):
setattr(bucket, name, m.money(getattr(bucket, name)))
bucket.qty_by_unit = {unit: m.qty(qty) for unit, qty in bucket.qty_by_unit.items()}
return bucket
@ -238,6 +245,7 @@ def totals(buckets: list[Bucket], label: str = "Итого") -> Bucket:
total.collected += bucket.collected
total.purchased += bucket.purchased
total.paid_to_bakery += bucket.paid_to_bakery
total.bakery_discount += bucket.bakery_discount
total.sales_count += bucket.sales_count
for unit, qty in bucket.qty_by_unit.items():
total.add_qty(unit, qty)
@ -277,7 +285,10 @@ class PeriodStat:
@property
def profit(self) -> Decimal:
return m.money(self.margin + self.tips - self.report.consumed_cost)
"""Прощённый по этой партии остаток — тоже её заработок."""
return m.money(
self.margin + self.tips + self.report.discount - self.report.consumed_cost
)
@property
def days_total(self) -> int:

View File

@ -81,7 +81,24 @@ def _migrate_2_to_3(raw: dict) -> dict:
return raw
_MIGRATIONS = {0: _migrate_0_to_1, 1: _migrate_1_to_2, 2: _migrate_2_to_3}
def _migrate_3_to_4(raw: dict) -> dict:
"""У расчётов с пекарней появился вид: деньги или прощённый остаток.
Всё, что записано раньше, живые деньги.
"""
for payment in raw.get("bakery_payments", []):
payment.setdefault("kind", "cash")
raw["schema_version"] = 4
return raw
_MIGRATIONS = {
0: _migrate_0_to_1,
1: _migrate_1_to_2,
2: _migrate_2_to_3,
3: _migrate_3_to_4,
}
def migrate(raw: dict) -> dict:

View File

@ -5,7 +5,10 @@ from __future__ import annotations
from datetime import timedelta
from PySide6.QtCore import Qt
from decimal import Decimal
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QFormLayout,
QHBoxLayout,
@ -85,12 +88,15 @@ class BatchDialog(QDialog):
class PaymentDialog(QDialog):
"""Возврат денег пекарне."""
"""Расчёт с пекарней: сколько отдал и что делать с остатком."""
def __init__(self, ctx, batch=None, suggested=None, parent=None):
super().__init__(parent)
self.ctx = ctx
self.batch = batch
self.owed = m.money(suggested or 0)
self.setWindowTitle("Платёж пекарне")
self.setMinimumWidth(380)
self.setMinimumWidth(430)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
@ -105,22 +111,63 @@ class PaymentDialog(QDialog):
self.amount = w.MoneySpin(ctx.currency)
if suggested is not None:
self.amount.set_decimal(suggested)
form.addRow("Сумма", self.amount)
self.amount.valueChanged.connect(lambda _: self._update_hint())
form.addRow("Отдал", self.amount)
self.note = QLineEdit()
form.addRow("Заметка", self.note)
layout.addLayout(form)
# Пекарня может взять меньше, чем причиталось: надо было 5 500, взяли
# 5 000. Долг при этом закрыт, но денег никто не отдавал — поэтому
# остаток списывается отдельной записью, а не подгонкой суммы.
self.write_off = QCheckBox("Остаток простили — закрыть партию")
self.write_off.toggled.connect(lambda _: self._update_hint())
if batch is not None:
layout.addWidget(w.label(f"Зачтётся в партию №{batch.number}.", "dim"))
else:
layout.addWidget(
w.label("Без выбранной партии платёж закроет самые старые долги.", "dim")
)
layout.addWidget(self.write_off)
self.hint = w.label("", "dim")
self.hint.setWordWrap(True)
layout.addWidget(self.hint)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Записать", "primary", self.accept))
)
self._update_hint()
@property
def remainder(self) -> Decimal:
"""Сколько останется непогашенным после этого платежа."""
return max(m.ZERO, m.money(self.owed - self.amount.value_decimal()))
def wants_write_off(self) -> bool:
return (
self.batch is not None
and self.write_off.isChecked()
and self.remainder > 0
)
def _update_hint(self) -> None:
currency = self.ctx.currency
if self.batch is None:
self.hint.setText("Без выбранной партии платёж закроет самые старые долги.")
return
left = self.remainder
if left <= 0:
self.hint.setText(f"Партия №{self.batch.number} закроется полностью.")
return
if self.write_off.isChecked():
self.hint.setText(
f"Останется {m.fmt_money(left, currency)} — спишем как скидку пекарни. "
"Партия закроется, а эти деньги попадут в прибыль и в статистику."
)
else:
self.hint.setText(
f"По партии №{self.batch.number} останется долг "
f"{m.fmt_money(left, currency)}."
)
class BatchesPage(QWidget):
@ -146,8 +193,9 @@ class BatchesPage(QWidget):
splitter = QSplitter(Qt.Vertical)
self.table = w.table(
["", "Дата", "Покрыть до", "Осталось", "Долг", "Отдано", "Собрано", "Покрытие", "Статус"],
stretch_column=8,
["", "Дата", "Покрыть до", "Осталось", "Долг", "Отдано",
"Прощено", "Собрано", "Покрытие", "Статус"],
stretch_column=9,
)
self.table.itemSelectionChanged.connect(self._show_details)
self.table.doubleClicked.connect(self.edit)
@ -206,6 +254,11 @@ class BatchesPage(QWidget):
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.discount, currency) if report.discount else "",
report.discount,
theme.OK if report.discount else "",
),
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),
@ -239,6 +292,8 @@ class BatchesPage(QWidget):
f"собрано деньгами {m.fmt_money(report.cash_collected, currency)}",
f"осталось собрать {m.fmt_money(report.still_to_collect, currency)}",
]
if report.discount > 0:
parts.append(f"пекарня простила {m.fmt_money(report.discount, currency)}")
if report.consumed_cost > 0:
parts.append(f"съедено и подарено на {m.fmt_money(report.consumed_cost, currency)}")
self.details_line.setText("".join(parts))
@ -323,13 +378,22 @@ class BatchesPage(QWidget):
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(),
)
if dialog.amount.value_decimal() > 0:
journal.add_bakery_payment(
self.ctx.vault,
dialog.amount.value_decimal(),
dialog.date.get_date(),
batch.id if batch else None,
dialog.note.text(),
)
if dialog.wants_write_off():
journal.write_off_batch(
self.ctx.vault,
batch.id,
dialog.remainder,
dialog.date.get_date(),
dialog.note.text() or "остаток простили",
)
except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc))
return

View File

@ -21,6 +21,8 @@ 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)}")
if summary.bakery_discounts > 0:
parts.append(f"+ прощено {m.fmt_money(summary.bakery_discounts, currency)}")
parts.append(f" съедено {m.fmt_money(summary.consumed_cost, currency)}")
return " ".join(parts)

View File

@ -35,12 +35,13 @@ METRICS = [
("Чаевые", "tips", theme.OK),
("Закуплено", "purchased", theme.WARN),
("Отдано пекарне", "paid_to_bakery", theme.WARN),
("Прощено пекарней", "bakery_discount", theme.OK),
]
BUCKET_COLUMNS = [
"Период", "Продаж", "Продано", "Выручка", "Себестоимость",
"Наценка", "Чаевые", "Съедено", "Прибыль", "Собрано",
"Закуплено", "Отдано пекарне",
"Закуплено", "Отдано пекарне", "Прощено",
]
@ -125,6 +126,8 @@ class BucketTab(QWidget):
money_cell(bucket.collected, currency),
money_cell(bucket.purchased, currency),
money_cell(bucket.paid_to_bakery, currency),
money_cell(bucket.bakery_discount, currency,
theme.OK if bucket.bakery_discount else ""),
]
def _draw_chart(self) -> None:
@ -166,9 +169,9 @@ class PeriodsTab(QWidget):
"""Периоды, которые задавались руками, — то есть партии."""
COLUMNS = [
"Партия", "Дата", "Покрыть до", "Дней", "Долг", "Отдано", "Собрано",
"Покрытие", "Продано", "Осталось", "Наценка", "Чаевые", "Съедено",
"Прибыль", "Статус",
"Партия", "Дата", "Покрыть до", "Дней", "Долг", "Отдано", "Прощено",
"Собрано", "Покрытие", "Продано", "Осталось", "Наценка", "Чаевые",
"Съедено", "Прибыль", "Статус",
]
def __init__(self, ctx, parent=None):
@ -207,6 +210,8 @@ class PeriodsTab(QWidget):
w.sortable_num_item(str(period.days_total), period.days_total),
money_cell(report.cost_total, currency),
money_cell(report.paid_to_bakery, currency),
money_cell(report.discount, currency,
theme.OK if report.discount else ""),
money_cell(report.cash_collected, currency),
w.sortable_num_item(f"{report.coverage_pct}%", report.coverage_pct, color),
w.text_item(period.sold_text or "",

View File

@ -420,6 +420,108 @@ def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
assert report.summary.stock_qty == 0
# --- Пекарня простила остаток --------------------------------------------
def test_bakery_taking_less_closes_the_batch(vault, buns, today, in_two_weeks):
"""Надо было отдать 5 500, взяли 5 000, полтысячи оставили."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 275, 20)])
assert batch.cost_total == Decimal("5500.00")
journal.add_bakery_payment(vault, 5000, today, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 500, today, "остаток простили")
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == Decimal("5000.00")
assert report.discount == Decimal("500.00")
assert report.remaining_to_bakery == 0
assert report.status == ledger.STATUS_SETTLED
def test_forgiven_money_is_income(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)])
journal.add_bakery_payment(vault, 150, today, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 50, today)
summary = ledger.build(vault.doc, today).summary
assert summary.bakery_discounts == Decimal("50.00")
assert summary.gross_margin == Decimal("150.00")
assert summary.profit == Decimal("200.00") # 150 наценки + 50 прощённых
assert summary.owed_to_bakery == 0
def test_forgiven_money_is_not_a_payment(vault, buns, today, in_two_weeks):
"""«Отдано пекарне» должно показывать только реально отданные деньги."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.write_off_batch(vault, batch.id, 200, today)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == 0
assert report.discount == Decimal("200.00")
assert report.settled_amount == Decimal("200.00")
def test_discount_reduces_what_still_needs_collecting(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)])
journal.write_off_batch(vault, batch.id, 50, today)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.cost_total == Decimal("200.00")
assert report.cash_collected == Decimal("70.00")
assert report.still_to_collect == Decimal("80.00") # 200 50 прощено 70
def test_discount_beyond_the_debt_is_ignored(vault, buns, today, in_two_weeks):
"""Простить больше, чем должен, нельзя — лишнее не доход."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.write_off_batch(vault, batch.id, 900, today)
report = ledger.build(vault.doc, today)
assert report.batch_report(batch.id).discount == Decimal("200.00")
assert report.summary.bakery_discounts == Decimal("200.00")
assert report.unassigned_bakery_payment == 0 # и никуда не переливается
def test_discount_and_payment_together_never_overshoot(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.add_bakery_payment(vault, 180, today, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 100, today)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == Decimal("180.00")
assert report.discount == Decimal("20.00") # больше долга не спишется
assert report.remaining_to_bakery == 0
def test_discount_must_name_a_batch(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
with pytest.raises(journal.ValidationError, match="привязать к партии"):
journal.add_bakery_payment(vault, 100, today, kind="discount")
def test_discount_is_journalled(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.write_off_batch(vault, batch.id, 50, today)
entry = vault.doc.journal[-1]
assert entry.action == "bakery_payment.discount"
assert "Скидка пекарни" in entry.label
def test_plain_payments_are_unaffected(vault, buns, today, in_two_weeks):
"""Кто скидок не получает, тот не должен заметить появления этой фичи."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.add_bakery_payment(vault, 120, today, batch_id=batch.id)
report = ledger.build(vault.doc, today).batch_report(batch.id)
assert report.paid_to_bakery == Decimal("120.00")
assert report.discount == 0
assert report.remaining_to_bakery == Decimal("80.00")
assert ledger.build(vault.doc, today).summary.bakery_discounts == 0
# --- Чаевые ---------------------------------------------------------------

View File

@ -193,6 +193,66 @@ def test_bakery_payments_land_by_their_own_date(vault, buns):
assert months["2026-09"].paid_to_bakery == Decimal("400.00")
def test_forgiven_remainder_is_not_money_paid(vault, buns):
"""«Отдано пекарне» и «Прощено» — разные колонки, и это принципиально."""
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 275, 20)]
)
journal.add_bakery_payment(vault, 5000, august, batch_id=batch.id)
journal.write_off_batch(vault, batch.id, 500, august)
month = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert month.paid_to_bakery == Decimal("5000.00")
assert month.bakery_discount == Decimal("500.00")
def test_forgiven_remainder_adds_to_period_profit(vault, buns):
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 10, 20)]
)
journal.create_sale(vault, august, KIND_RETAIL, [line(buns["мак"], 10, 35)])
journal.write_off_batch(vault, batch.id, 50, august)
month = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert month.margin == Decimal("150.00")
assert month.profit == Decimal("200.00")
def test_forgiven_remainder_lands_by_its_own_date(vault, buns):
august, september = date(2026, 8, 10), date(2026, 9, 4)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 10, 20)]
)
journal.write_off_batch(vault, batch.id, 200, september)
months = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 9, 30))
assert months["2026-08"].bakery_discount == 0
assert months["2026-09"].bakery_discount == Decimal("200.00")
def test_totals_include_the_discount(vault, buns):
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 10, 20)]
)
journal.write_off_batch(vault, batch.id, 200, august)
buckets = stats.build_buckets(vault.doc, report_for(vault, date(2026, 8, 31)), stats.GRAIN_MONTH)
assert stats.totals(buckets).bakery_discount == Decimal("200.00")
def test_period_profit_counts_the_discount(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)])
journal.write_off_batch(vault, batch.id, 60, today)
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.margin == Decimal("60.00")
assert period.profit == Decimal("120.00") # 60 наценки + 60 прощённых
def test_tips_count_as_money_at_sale_time(vault, buns):
"""Чаевые дают из рук в руки, своей даты у них нет."""
day = date(2026, 8, 10)

View File

@ -560,6 +560,103 @@ def test_lines_editor_add_and_remove(window, buns):
dialog.deleteLater()
# --- пекарня простила остаток ---------------------------------------------
def test_payment_dialog_offers_to_write_off_the_remainder(window):
"""Надо было 850, отдал 800 — полсотни предлагается списать."""
from app.ui.page_batches import PaymentDialog
batch = window.vault.doc.batches[0]
dialog = PaymentDialog(window, batch, suggested=850)
dialog.amount.set_decimal(800)
assert dialog.remainder == Decimal("50.00")
assert dialog.wants_write_off() is False
assert "останется долг" in dialog.hint.text().lower()
dialog.write_off.setChecked(True)
assert dialog.wants_write_off() is True
assert "скидку пекарни" in dialog.hint.text()
dialog.deleteLater()
def test_full_payment_leaves_nothing_to_write_off(window):
from app.ui.page_batches import PaymentDialog
batch = window.vault.doc.batches[0]
dialog = PaymentDialog(window, batch, suggested=850)
dialog.write_off.setChecked(True)
assert dialog.remainder == 0
assert dialog.wants_write_off() is False # списывать нечего
assert "закроется полностью" in dialog.hint.text()
dialog.deleteLater()
def test_write_off_closes_the_batch_end_to_end(window):
from app import ledger
from app import journal as j
batch = window.vault.doc.batches[0]
j.add_bakery_payment(window.vault, 800, batch_id=batch.id)
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
report = window.report.batch_report(batch.id)
assert report.paid_to_bakery == Decimal("800.00")
assert report.discount == Decimal("50.00")
assert report.remaining_to_bakery == 0
assert report.status == ledger.STATUS_SETTLED
assert window.report.summary.owed_to_bakery == 0
def test_batches_table_shows_the_discount(window):
from app import journal as j
batch = window.vault.doc.batches[0]
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
page = page_of(window, "Закупки")
page.refresh()
assert "50,00" in page.table.item(0, 6).text()
page.table.selectRow(0)
page._show_details()
assert "пекарня простила" in page.details_line.text()
def test_dashboard_mentions_forgiven_money(window):
from PySide6.QtWidgets import QLabel
from app import journal as j
batch = window.vault.doc.batches[0]
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
page = page_of(window, "Сводка")
page.refresh()
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
assert "прощено 50,00 ₽" in texts.lower()
def test_stats_periods_show_the_discount(window):
from app import journal as j
batch = window.vault.doc.batches[0]
j.write_off_batch(window.vault, batch.id, 50)
window.changed()
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(0)
page.refresh()
tab = page.sections[0][1]
assert "50,00" in tab.table.item(0, tab.COLUMNS.index("Прощено")).text()
# --- статистика -----------------------------------------------------------