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

Надо было отдать 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, на графике показываются последние, и об этом написано прямо корзин больше 24, на графике показываются последние, и об этом написано прямо
над ним — молча показанная часть выглядела бы как всё. над ним — молча показанная часть выглядела бы как всё.
### Пекарня простила остаток
Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500,
взяли 5 000, полтысячи оставили. Долг при этом закрыт, но денег никто не
отдавал.
В диалоге «Платёж пекарне» есть галочка **«Остаток простили — закрыть
партию»**: вводишь, сколько реально отдал, и остаток уходит отдельной записью
вида «скидка пекарни». Партия закрывается сама — её долг становится нулём.
Скидка хранится отдельно от платежей намеренно. Свали их в одну кучу — и
«Отдано пекарне» в статистике покажет суммы, которых ты не платил. Поэтому:
- **«Отдано»** — только живые деньги;
- **«Прощено»** — закрывает долг, но кармана не покидает;
- в прибыль прощённое идёт **целиком**: эти деньги предназначались пекарне,
а остались у тебя;
- «осталось собрать» уменьшается — под прощённый остаток собирать уже не надо.
Простить больше, чем должен, нельзя: лишнее не засчитывается и никуда не
переливается.
### Чаевые ### Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
@ -137,9 +160,9 @@
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать, поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
деньги за какую партию уже пришли. деньги за какую партию уже пришли.
Прибыль считается как наценка с проданного **плюс** чаевые **минус** Прибыль считается как наценка с проданного **плюс** чаевые **плюс**
себестоимость съеденного и подаренного: за съеденное пекарне платить всё прощённое пекарней **минус** себестоимость съеденного и подаренного:
равно, и покрывается это из маржи. за съеденное пекарне платить всё равно, и покрывается это из маржи.
## Как запустить ## Как запустить

View File

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

View File

@ -98,12 +98,20 @@ class BatchReport:
paid_to_bakery: Decimal paid_to_bakery: Decimal
days_left: int days_left: int
status: str status: str
# Сколько пекарня простила по этой партии: долг закрыт, а деньги остались
# у тебя.
discount: Decimal = m.ZERO
stock: dict[str, Decimal] = field(default_factory=dict) stock: dict[str, Decimal] = field(default_factory=dict)
stock_cost: Decimal = m.ZERO stock_cost: Decimal = m.ZERO
@property
def settled_amount(self) -> Decimal:
"""Чем закрыт долг: деньгами и прощённым остатком вместе."""
return m.money(self.paid_to_bakery + self.discount)
@property @property
def remaining_to_bakery(self) -> Decimal: 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 @property
def coverage_pct(self) -> Decimal: def coverage_pct(self) -> Decimal:
@ -114,8 +122,11 @@ class BatchReport:
@property @property
def still_to_collect(self) -> Decimal: 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 @property
def label(self) -> str: def label(self) -> str:
@ -147,6 +158,7 @@ class Summary:
cash_collected: Decimal cash_collected: Decimal
gross_margin: Decimal gross_margin: Decimal
tips: Decimal tips: Decimal
bakery_discounts: Decimal
consumed_cost: 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 @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} 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 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: for payment in doc.bakery_payments:
target = discount if payment.is_discount else paid
batch_id = payment.batch_id batch_id = payment.batch_id
if batch_id and batch_id in paid: if batch_id and batch_id in cost_totals:
room = max(m.ZERO, cost_totals[batch_id] - paid[batch_id]) room = max(m.ZERO, cost_totals[batch_id] - covered(batch_id))
applied = min(room, payment.amount) applied = min(room, payment.amount)
paid[batch_id] += applied target[batch_id] += applied
pool += payment.amount - applied # Прощённый остаток сверх долга ничего не гасит и никуда не
# переливается: списывать нечего.
if not payment.is_discount:
pool += payment.amount - applied
elif payment.is_discount:
continue
else: else:
pool += payment.amount pool += payment.amount
for _, batch in _ordered_batches(doc): for _, batch in _ordered_batches(doc):
if pool <= 0: if pool <= 0:
break 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) applied = min(room, pool)
paid[batch.id] = paid.get(batch.id, m.ZERO) + applied paid[batch.id] = paid.get(batch.id, m.ZERO) + applied
pool -= 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) unit_costs = batch_unit_costs(doc)
cost_totals = {batch.id: batch.cost_total for batch in doc.batches} 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]] = { per_batch: dict[str, dict[str, Decimal]] = {
batch.id: {"qty_sold": m.ZERO, "revenue": m.ZERO, "cash": m.ZERO, "consumed": m.ZERO} 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] bucket = per_batch[batch.id]
cost_total = cost_totals[batch.id] cost_total = cost_totals[batch.id]
paid = m.money(paid_to_bakery.get(batch.id, m.ZERO)) 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 days_left = (batch.due_date - today).days
stock = { stock = {
pid: qty pid: qty
@ -415,8 +449,9 @@ def build(doc: Document, today: date | None = None) -> Report:
cash_collected=m.money(bucket["cash"]), cash_collected=m.money(bucket["cash"]),
consumed_cost=m.money(bucket["consumed"]), consumed_cost=m.money(bucket["consumed"]),
paid_to_bakery=paid, paid_to_bakery=paid,
discount=discount,
days_left=days_left, 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=stock,
stock_cost=m.money( stock_cost=m.money(
sum( sum(
@ -521,6 +556,9 @@ def _summary(
# Считаем по продажам, а не по аллокациям: если товара не хватило и # Считаем по продажам, а не по аллокациям: если товара не хватило и
# часть продажи никуда не легла, чаевые всё равно получены полностью. # часть продажи никуда не легла, чаевые всё равно получены полностью.
tips=m.money(sum((s.tip for s in doc.sales), m.ZERO)), 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), consumed_cost=m.money(consumed),
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)), 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)), 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 from . import money as m
SCHEMA_VERSION = 3 SCHEMA_VERSION = 4
ONE = Decimal(1) ONE = Decimal(1)
@ -465,9 +465,27 @@ class Sale:
} }
BAKERY_CASH = "cash"
BAKERY_DISCOUNT = "discount"
BAKERY_KIND_LABELS = {
BAKERY_CASH: "Платёж",
BAKERY_DISCOUNT: "Скидка пекарни",
}
@dataclass @dataclass
class BakeryPayment: class BakeryPayment:
"""Возврат денег пекарне. """Расчёт с пекарней.
Два вида, и различать их обязательно:
* `cash` деньги реально ушли из кармана;
* `discount` пекарня простила остаток. Долг закрыт, но денег никто не
отдавал, и для тебя это доход.
Свалить их в одну кучу нельзя: тогда «отдано пекарне» в статистике
показывало бы суммы, которых ты не платил.
batch_id=None означает «просто отдал денег» такой платёж гасит долг batch_id=None означает «просто отдал денег» такой платёж гасит долг
по старейшим непокрытым партиям (см. ledger). по старейшим непокрытым партиям (см. ledger).
@ -477,8 +495,13 @@ class BakeryPayment:
date: date date: date
amount: Decimal amount: Decimal
batch_id: str | None = None batch_id: str | None = None
kind: str = BAKERY_CASH
note: str = "" note: str = ""
@property
def is_discount(self) -> bool:
return self.kind == BAKERY_DISCOUNT
@classmethod @classmethod
def from_dict(cls, d: dict) -> "BakeryPayment": def from_dict(cls, d: dict) -> "BakeryPayment":
return cls( return cls(
@ -486,6 +509,7 @@ class BakeryPayment:
date=parse_date(d["date"]), date=parse_date(d["date"]),
amount=m.money(d.get("amount")), amount=m.money(d.get("amount")),
batch_id=d.get("batch_id") or None, batch_id=d.get("batch_id") or None,
kind=d.get("kind", BAKERY_CASH),
note=d.get("note", ""), note=d.get("note", ""),
) )
@ -495,6 +519,7 @@ class BakeryPayment:
"date": dump_date(self.date), "date": dump_date(self.date),
"amount": m.dumps(self.amount), "amount": m.dumps(self.amount),
"batch_id": self.batch_id, "batch_id": self.batch_id,
"kind": self.kind,
"note": self.note, "note": self.note,
} }

View File

@ -139,11 +139,14 @@ class Bucket:
# По датам закупки и расчёта с пекарней. # По датам закупки и расчёта с пекарней.
purchased: Decimal = m.ZERO purchased: Decimal = m.ZERO
paid_to_bakery: Decimal = m.ZERO paid_to_bakery: Decimal = m.ZERO
# Прощённый пекарней остаток. Отдельно от paid_to_bakery: долг закрыт,
# но денег не отдавали.
bakery_discount: Decimal = m.ZERO
@property @property
def profit(self) -> Decimal: 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 @property
def margin_pct(self) -> Decimal: 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 bucket_for(batch.date).purchased += batch.cost_total
for payment in doc.bakery_payments: 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)] 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: def _rounded(bucket: Bucket) -> Bucket:
"""Округлить до копеек один раз в конце, а не на каждом слагаемом.""" """Округлить до копеек один раз в конце, а не на каждом слагаемом."""
for name in ("revenue", "cogs", "margin", "tips", "consumed_cost", 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))) 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()} bucket.qty_by_unit = {unit: m.qty(qty) for unit, qty in bucket.qty_by_unit.items()}
return bucket return bucket
@ -238,6 +245,7 @@ def totals(buckets: list[Bucket], label: str = "Итого") -> Bucket:
total.collected += bucket.collected total.collected += bucket.collected
total.purchased += bucket.purchased total.purchased += bucket.purchased
total.paid_to_bakery += bucket.paid_to_bakery total.paid_to_bakery += bucket.paid_to_bakery
total.bakery_discount += bucket.bakery_discount
total.sales_count += bucket.sales_count total.sales_count += bucket.sales_count
for unit, qty in bucket.qty_by_unit.items(): for unit, qty in bucket.qty_by_unit.items():
total.add_qty(unit, qty) total.add_qty(unit, qty)
@ -277,7 +285,10 @@ class PeriodStat:
@property @property
def profit(self) -> Decimal: 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 @property
def days_total(self) -> int: def days_total(self) -> int:

View File

@ -81,7 +81,24 @@ def _migrate_2_to_3(raw: dict) -> dict:
return raw 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: def migrate(raw: dict) -> dict:

View File

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

View File

@ -21,6 +21,8 @@ def _profit_hint(summary, currency: str) -> str:
parts = [f"наценка {m.fmt_money(summary.gross_margin, currency)}"] parts = [f"наценка {m.fmt_money(summary.gross_margin, currency)}"]
if summary.tips > 0: if summary.tips > 0:
parts.append(f"+ чаевые {m.fmt_money(summary.tips, currency)}") 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)}") parts.append(f" съедено {m.fmt_money(summary.consumed_cost, currency)}")
return " ".join(parts) return " ".join(parts)

View File

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

View File

@ -560,6 +560,103 @@ def test_lines_editor_add_and_remove(window, buns):
dialog.deleteLater() 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()
# --- статистика ----------------------------------------------------------- # --- статистика -----------------------------------------------------------