food-records/tests/test_ledger.py
Claude c8b9f5048b Учёт закупки и перепродажи булок
Десктопное приложение на PySide6. Вся база — один JSON-документ,
зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит
в data/vault.fmdb и раз в час уезжает в этот репозиторий.

Основное:
- партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж
  по партиям и прогресс покрытия к сроку;
- типы выбытия: розница, другу по себестоимости, съел сам, подарок,
  списание — съеденное вычитается из прибыли, за него платить всё равно;
- долги контрагентов с частичными оплатами;
- номенклатура с историей изменения цен;
- журнал изменений внутри базы: git хранит непрозрачные снимки,
  поэтому настоящая история ведётся здесь.

Синхронизация коммитит только путь базы и схлопывает часовые пуши
в один коммит на день через amend + force-with-lease.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:29:53 +03:00

315 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import date, timedelta
from decimal import Decimal
import pytest
from app import journal, ledger
from app.models import KIND_FRIEND, KIND_GIFT, KIND_RETAIL, KIND_SELF
def line(product, qty, price):
return {"product_id": product.id, "qty": qty, "unit_price": price}
def buy(product, qty, cost):
return {"product_id": product.id, "qty": qty, "unit_cost": cost}
# --- Сценарий из постановки задачи ----------------------------------------
@pytest.fixture
def scenario(vault, buns, today, in_two_weeks):
"""Ровно то, что описал пользователь.
Пришёл в пекарню, взял 5 булок с повидлом, 10 с маком, 20 с корицей.
Часть продал, часть отдал другу в долг по себестоимости, пару съел сам.
"""
batch = journal.create_batch(
vault,
today,
in_two_weeks,
[buy(buns["повидло"], 5, 30), buy(buns["мак"], 10, 20), buy(buns["корица"], 20, 25)],
)
vasya = journal.create_counterparty(vault, "Вася")
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["повидло"], 3, 50)])
journal.create_sale(
vault, today, KIND_FRIEND, [line(buns["мак"], 5, 20)],
counterparty_id=vasya.id, paid_amount=0,
)
journal.create_sale(vault, today, KIND_SELF, [line(buns["корица"], 2, 0)])
return {"vault": vault, "batch": batch, "vasya": vasya, "buns": buns}
def test_batch_debt_is_the_purchase_cost(scenario, today):
report = ledger.build(scenario["vault"].doc, today)
batch = report.batch_report(scenario["batch"].id)
# 5×30 + 10×20 + 20×25
assert batch.cost_total == Decimal("850.00")
assert batch.remaining_to_bakery == Decimal("850.00")
def test_only_actually_received_money_counts_as_coverage(scenario, today):
"""Долг Васи не покрывает партию: пекарне нужны деньги, а не начисления."""
report = ledger.build(scenario["vault"].doc, today)
batch = report.batch_report(scenario["batch"].id)
assert batch.revenue == Decimal("250.00") # 150 продажа + 100 долг Васи
assert batch.cash_collected == Decimal("150.00")
assert batch.still_to_collect == Decimal("700.00")
assert batch.coverage_pct == Decimal("17.65")
def test_self_consumed_buns_are_a_loss_against_margin(scenario, today):
summary = ledger.build(scenario["vault"].doc, today).summary
assert summary.gross_margin == Decimal("60.00") # 3 × (50 30)
assert summary.consumed_cost == Decimal("50.00") # 2 корицы × 25
assert summary.profit == Decimal("10.00")
def test_stock_and_receivables(scenario, today):
report = ledger.build(scenario["vault"].doc, today)
batch = report.batch_report(scenario["batch"].id)
assert batch.qty_sold == Decimal("10.000")
assert batch.qty_left == Decimal("25.000")
# 2×30 + 5×20 + 18×25
assert report.summary.stock_cost == Decimal("610.00")
assert report.summary.receivable == Decimal("100.00")
assert [(d.name, d.debt) for d in report.debtors] == [("Вася", Decimal("100.00"))]
def test_paying_the_bakery_closes_the_batch(scenario, today):
vault = scenario["vault"]
journal.add_bakery_payment(vault, 850, batch_id=scenario["batch"].id)
batch = ledger.build(vault.doc, today).batch_report(scenario["batch"].id)
assert batch.remaining_to_bakery == 0
assert batch.status == ledger.STATUS_SETTLED
assert ledger.build(vault.doc, today).summary.owed_to_bakery == 0
# --- FIFO по партиям ------------------------------------------------------
def test_sale_spills_from_the_older_batch_into_the_newer(vault, buns, today):
"""Привязка выручки к партии — единственная причина считать FIFO."""
old = journal.create_batch(
vault, today - timedelta(days=10), today + timedelta(days=4),
[buy(buns["мак"], 10, 20)],
)
new = journal.create_batch(
vault, today - timedelta(days=1), today + timedelta(days=13),
[buy(buns["мак"], 10, 20)],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 14, 35)])
report = ledger.build(vault.doc, today)
assert report.batch_report(old.id).qty_sold == Decimal("10.000")
assert report.batch_report(new.id).qty_sold == Decimal("4.000")
assert report.batch_report(old.id).cash_collected == Decimal("350.00") # 10 × 35
assert report.batch_report(new.id).cash_collected == Decimal("140.00") # 4 × 35
def test_batch_order_follows_date_not_entry_order(vault, buns, today):
later = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]
)
earlier = journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 5, 35)])
report = ledger.build(vault.doc, today)
assert report.batch_report(earlier.id).qty_sold == Decimal("5.000")
assert report.batch_report(later.id).qty_sold == 0
def test_allocation_is_deterministic(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
for _ in range(3):
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)])
first = ledger.build(vault.doc, today).allocations
second = ledger.build(vault.doc, today).allocations
assert [(a.sale_id, a.batch_id, a.qty) for a in first] == [
(a.sale_id, a.batch_id, a.qty) for a in second
]
def test_selling_more_than_bought_warns_instead_of_crashing(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 8, 35)])
report = ledger.build(vault.doc, today)
assert [(s.product_id, s.qty) for s in report.shortfalls] == [
(buns["мак"].id, Decimal("3.000"))
]
assert any("больше" in w for w in report.warnings)
# --- Частичная оплата -----------------------------------------------------
def test_partial_payment_counts_proportionally(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
sale = journal.create_sale(
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
)
journal.add_sale_payment(vault, sale.id, 140) # 40% от 350
report = ledger.build(vault.doc, today)
assert report.batches[0].revenue == Decimal("350.00")
assert report.batches[0].cash_collected == Decimal("140.00")
assert report.summary.receivable == Decimal("210.00")
def test_paying_off_the_debt_completes_coverage(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
sale = journal.create_sale(
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
)
journal.add_sale_payment(vault, sale.id, 350)
report = ledger.build(vault.doc, today)
assert report.batches[0].cash_collected == Decimal("350.00")
assert report.summary.receivable == 0
assert report.debtors == []
# --- Платежи пекарне ------------------------------------------------------
def test_unassigned_payment_pays_the_oldest_batch_first(vault, buns, today):
old = journal.create_batch(
vault, today - timedelta(days=10), today + timedelta(days=4), [buy(buns["мак"], 10, 20)]
)
new = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 10, 20)]
)
journal.add_bakery_payment(vault, 250) # 200 закрывают старую, 50 идут в новую
report = ledger.build(vault.doc, today)
assert report.batch_report(old.id).remaining_to_bakery == 0
assert report.batch_report(new.id).remaining_to_bakery == Decimal("150.00")
def test_overpaying_one_batch_spills_over_instead_of_going_negative(vault, buns, today):
first = journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 5, 20)]
)
second = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)]
)
journal.add_bakery_payment(vault, 180, batch_id=first.id) # партия стоит всего 100
report = ledger.build(vault.doc, today)
assert report.batch_report(first.id).remaining_to_bakery == 0
assert report.batch_report(second.id).remaining_to_bakery == Decimal("20.00")
def test_paying_more_than_everything_is_reported_as_advance(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
journal.add_bakery_payment(vault, 300)
report = ledger.build(vault.doc, today)
assert report.unassigned_bakery_payment == Decimal("200.00")
assert report.summary.owed_to_bakery == 0
assert any("аванс" in w for w in report.warnings)
# --- Статусы и дедлайны ---------------------------------------------------
def test_status_open(vault, buns, today):
journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)])
assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_OPEN
def test_status_due_soon(vault, buns, today):
journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)])
report = ledger.build(vault.doc, today).batches[0]
assert report.status == ledger.STATUS_DUE_SOON
assert report.days_left == 2
def test_status_overdue(vault, buns, today):
journal.create_batch(
vault, today - timedelta(days=20), today - timedelta(days=1), [buy(buns["мак"], 5, 20)]
)
report = ledger.build(vault.doc, today)
assert report.batches[0].status == ledger.STATUS_OVERDUE
assert report.summary.overdue_count == 1
def test_manually_closed_batch_is_settled_even_if_unpaid(vault, buns, today, in_two_weeks):
"""Иногда с пекарней договариваются на словах — учёт не должен спорить."""
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 5, 20)])
journal.set_batch_closed(vault, batch.id, True)
assert ledger.build(vault.doc, today).batches[0].status == ledger.STATUS_SETTLED
def test_next_due_picks_the_nearest_open_deadline(vault, buns, today):
journal.create_batch(vault, today, today + timedelta(days=14), [buy(buns["мак"], 5, 20)])
urgent = journal.create_batch(vault, today, today + timedelta(days=2), [buy(buns["мак"], 5, 20)])
summary = ledger.build(vault.doc, today).summary
assert summary.next_due.batch.id == urgent.id
def test_settled_batches_are_not_offered_as_next_due(vault, buns, today):
urgent = journal.create_batch(vault, today, today + timedelta(days=1), [buy(buns["мак"], 5, 20)])
later = journal.create_batch(vault, today, today + timedelta(days=20), [buy(buns["мак"], 5, 20)])
journal.add_bakery_payment(vault, 100, batch_id=urgent.id)
summary = ledger.build(vault.doc, today).summary
assert summary.next_due.batch.id == later.id
# --- Прочее ---------------------------------------------------------------
def test_gifts_count_as_consumption_not_revenue(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_GIFT, [line(buns["мак"], 3, 0)])
summary = ledger.build(vault.doc, today).summary
assert summary.revenue == 0
assert summary.consumed_cost == Decimal("60.00")
assert summary.profit == Decimal("-60.00")
def test_friend_price_yields_no_margin(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_FRIEND, [line(buns["мак"], 10, 20)])
summary = ledger.build(vault.doc, today).summary
assert summary.cash_collected == Decimal("200.00")
assert summary.gross_margin == 0
assert summary.profit == 0
def test_debts_without_counterparty_are_grouped_separately(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=0)
debtors = ledger.build(vault.doc, today).debtors
assert [(d.name, d.debt) for d in debtors] == [("Без контрагента", Decimal("70.00"))]
def test_empty_database_reports_zeroes(vault, today):
report = ledger.build(vault.doc, today)
assert report.summary.owed_to_bakery == 0
assert report.summary.profit == 0
assert report.summary.next_due is None
assert report.warnings == []
def test_stock_lists_products_with_remaining_quantity(scenario, today):
rows = {r.name: r.qty for r in ledger.build(scenario["vault"].doc, today).stock}
assert rows["Булка с повидлом"] == Decimal("2.000")
assert rows["Булка с маком"] == Decimal("5.000")
assert rows["Булка с корицей"] == Decimal("18.000")