diff --git a/README.md b/README.md index 996af22..8405fb4 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ - по дате **платежа** — колонка «Собрано». Это «сколько денег реально пришло», и долг, возвращённый в сентябре, попадёт в сентябрь, а не в августовскую продажу; -- по дате **закупки** — «Закуплено», по дате платежа пекарне — «Отдано пекарне». +- по дате **закупки** — «Закуплено», по дате расчёта с пекарней — «Отдано + пекарне» и «Прощено». У разреза «Периоды» основание третье: там всё привязано к самой партии, потому что вопрос стоит иначе — «покрыл ли я эту партию к сроку». @@ -72,6 +73,28 @@ корзин больше 24, на графике показываются последние, и об этом написано прямо над ним — молча показанная часть выглядела бы как всё. +### Пекарня простила остаток + +Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500, +взяли 5 000, полтысячи оставили. Долг при этом закрыт, но денег никто не +отдавал. + +В диалоге «Платёж пекарне» есть галочка **«Остаток простили — закрыть +партию»**: вводишь, сколько реально отдал, и остаток уходит отдельной записью +вида «скидка пекарни». Партия закрывается сама — её долг становится нулём. + +Скидка хранится отдельно от платежей намеренно. Свали их в одну кучу — и +«Отдано пекарне» в статистике покажет суммы, которых ты не платил. Поэтому: + +- **«Отдано»** — только живые деньги; +- **«Прощено»** — закрывает долг, но кармана не покидает; +- в прибыль прощённое идёт **целиком**: эти деньги предназначались пекарне, + а остались у тебя; +- «осталось собрать» уменьшается — под прощённый остаток собирать уже не надо. + +Простить больше, чем должен, нельзя: лишнее не засчитывается и никуда не +переливается. + ### Чаевые Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не @@ -137,9 +160,9 @@ поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать, деньги за какую партию уже пришли. -Прибыль считается как наценка с проданного **плюс** чаевые **минус** -себестоимость съеденного и подаренного: за съеденное пекарне платить всё -равно, и покрывается это из маржи. +Прибыль считается как наценка с проданного **плюс** чаевые **плюс** +прощённое пекарней **минус** себестоимость съеденного и подаренного: +за съеденное пекарне платить всё равно, и покрывается это из маржи. ## Как запустить diff --git a/app/journal.py b/app/journal.py index 32a4106..9120044 100644 --- a/app/journal.py +++ b/app/journal.py @@ -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), ""), ], ) diff --git a/app/ledger.py b/app/ledger.py index dc57183..23e9a9d 100644 --- a/app/ledger.py +++ b/app/ledger.py @@ -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)), diff --git a/app/models.py b/app/models.py index f545af7..6af7587 100644 --- a/app/models.py +++ b/app/models.py @@ -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, } diff --git a/app/stats.py b/app/stats.py index 10bc15e..8784acd 100644 --- a/app/stats.py +++ b/app/stats.py @@ -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: diff --git a/app/storage.py b/app/storage.py index 3ebb333..257e318 100644 --- a/app/storage.py +++ b/app/storage.py @@ -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: diff --git a/app/ui/page_batches.py b/app/ui/page_batches.py index fcd3294..abb421f 100644 --- a/app/ui/page_batches.py +++ b/app/ui/page_batches.py @@ -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 diff --git a/app/ui/page_dashboard.py b/app/ui/page_dashboard.py index 3c163f9..88827e1 100644 --- a/app/ui/page_dashboard.py +++ b/app/ui/page_dashboard.py @@ -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) diff --git a/app/ui/page_stats.py b/app/ui/page_stats.py index 2e9163b..ccf184b 100644 --- a/app/ui/page_stats.py +++ b/app/ui/page_stats.py @@ -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 "—", diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 9608488..97bf3f3 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -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 + + # --- Чаевые --------------------------------------------------------------- diff --git a/tests/test_stats.py b/tests/test_stats.py index addb132..85bc3e7 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -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) diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 4a40e2f..66c82d0 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -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() + + # --- статистика -----------------------------------------------------------