Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт, но денег никто не отдавал. У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы суммы, которых не платил. Поэтому «Отдано» — только живые деньги, «Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике по периодам и по времени. В прибыль прощённое идёт целиком: эти деньги предназначались пекарне, а остались у тебя. «Осталось собрать» на ту же сумму уменьшается — собирать под прощённый остаток уже не надо. Простить больше долга нельзя: лишнее не засчитывается и, в отличие от переплаты деньгами, никуда не переливается. В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь сколько отдал, остаток уходит отдельной записью, партия закрывается сама, потому что её долг становится нулём. Миграция схемы 3→4 помечает всё записанное раньше как живые деньги. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
26b401e507
commit
3968806094
31
README.md
31
README.md
@ -58,7 +58,8 @@
|
||||
- по дате **платежа** — колонка «Собрано». Это «сколько денег реально пришло»,
|
||||
и долг, возвращённый в сентябре, попадёт в сентябрь, а не в августовскую
|
||||
продажу;
|
||||
- по дате **закупки** — «Закуплено», по дате платежа пекарне — «Отдано пекарне».
|
||||
- по дате **закупки** — «Закуплено», по дате расчёта с пекарней — «Отдано
|
||||
пекарне» и «Прощено».
|
||||
|
||||
У разреза «Периоды» основание третье: там всё привязано к самой партии, потому
|
||||
что вопрос стоит иначе — «покрыл ли я эту партию к сроку».
|
||||
@ -72,6 +73,28 @@
|
||||
корзин больше 24, на графике показываются последние, и об этом написано прямо
|
||||
над ним — молча показанная часть выглядела бы как всё.
|
||||
|
||||
### Пекарня простила остаток
|
||||
|
||||
Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500,
|
||||
взяли 5 000, полтысячи оставили. Долг при этом закрыт, но денег никто не
|
||||
отдавал.
|
||||
|
||||
В диалоге «Платёж пекарне» есть галочка **«Остаток простили — закрыть
|
||||
партию»**: вводишь, сколько реально отдал, и остаток уходит отдельной записью
|
||||
вида «скидка пекарни». Партия закрывается сама — её долг становится нулём.
|
||||
|
||||
Скидка хранится отдельно от платежей намеренно. Свали их в одну кучу — и
|
||||
«Отдано пекарне» в статистике покажет суммы, которых ты не платил. Поэтому:
|
||||
|
||||
- **«Отдано»** — только живые деньги;
|
||||
- **«Прощено»** — закрывает долг, но кармана не покидает;
|
||||
- в прибыль прощённое идёт **целиком**: эти деньги предназначались пекарне,
|
||||
а остались у тебя;
|
||||
- «осталось собрать» уменьшается — под прощённый остаток собирать уже не надо.
|
||||
|
||||
Простить больше, чем должен, нельзя: лишнее не засчитывается и никуда не
|
||||
переливается.
|
||||
|
||||
### Чаевые
|
||||
|
||||
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
|
||||
@ -137,9 +160,9 @@
|
||||
поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать,
|
||||
деньги за какую партию уже пришли.
|
||||
|
||||
Прибыль считается как наценка с проданного **плюс** чаевые **минус**
|
||||
себестоимость съеденного и подаренного: за съеденное пекарне платить всё
|
||||
равно, и покрывается это из маржи.
|
||||
Прибыль считается как наценка с проданного **плюс** чаевые **плюс**
|
||||
прощённое пекарней **минус** себестоимость съеденного и подаренного:
|
||||
за съеденное пекарне платить всё равно, и покрывается это из маржи.
|
||||
|
||||
## Как запустить
|
||||
|
||||
|
||||
@ -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), ""),
|
||||
],
|
||||
)
|
||||
|
||||
@ -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
|
||||
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)),
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
21
app/stats.py
21
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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,6 +378,7 @@ class BatchesPage(QWidget):
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
if dialog.amount.value_decimal() > 0:
|
||||
journal.add_bakery_payment(
|
||||
self.ctx.vault,
|
||||
dialog.amount.value_decimal(),
|
||||
@ -330,6 +386,14 @@ class BatchesPage(QWidget):
|
||||
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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 "—",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# --- Чаевые ---------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
# --- статистика -----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user