food-market/tests/test_ledger.py

1009 lines
45 KiB
Python
Raw Permalink Normal View History

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 == []
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 1. База и код разъехались по разным репозиториям. Автокоммит и раньше трогал только vault.fmdb, но лежал он в той же ветке, что и исходники, поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный клон food-records со своим .git, а исходники его игнорируют целиком. При первом запуске приложение встаёт на уже существующую историю сервера, а не заводит параллельную. 2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO считаются в базовых единицах, поэтому поштучные продажи вычитаются из купленных пачек. Размер фасовки хранится в документе слепком. Миграция схемы 1→2 не меняет поведение уже заведённых данных. 3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы — отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя заводится как контрагент, повтор периода подсвечивается. Тесты больше не имеют настроенного remote, чтобы не ходить в сеть. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
# --- Фасовки: пачка и штука — один товар ----------------------------------
@pytest.fixture
def cookies(vault):
"""Печенье: берётся пачками по 10, продаётся и пачкой, и поштучно.
Себестоимость 20 за штуку, то есть 200 за пачку. Поштучно уходит
по 30 , пачкой целиком за 200 .
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(vault, product.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
return vault.doc.product(product.id)
def pack_buy(product, qty, cost):
return {"product_id": product.id, "qty": qty, "uom": "пачка", "unit_cost": cost}
def test_buying_packs_gives_stock_in_pieces(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("1000.00") # 5 пачек × 200
assert batch.qty_total == Decimal("50.000") # но 50 штук
assert batch.qty_left == Decimal("50.000")
def test_selling_singles_depletes_the_packs(vault, cookies, today, in_two_weeks):
"""Главное: печенька поштучно берётся из пачки, это не второй товар."""
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_sold == Decimal("3.000")
assert batch.qty_left == Decimal("47.000")
assert batch.cash_collected == Decimal("90.00") # 3 × 30
# Наценка считается от себестоимости штуки, выведенной из цены пачки.
assert report.summary.gross_margin == Decimal("30.00") # 3 × (30 20)
def test_selling_a_whole_pack_and_singles_together(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": cookies.id, "qty": 1, "uom": "пачка", "unit_price": 200}],
)
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_sold == Decimal("13.000") # 3 штуки + 1 пачка
assert batch.qty_left == Decimal("37.000")
assert batch.cash_collected == Decimal("290.00") # 90 + 200
# Пачка ушла ровно по себестоимости, наценка только с поштучных.
assert report.summary.gross_margin == Decimal("30.00")
def test_stock_value_uses_cost_per_piece(vault, cookies, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 5, 200)])
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 3, 30)])
summary = ledger.build(vault.doc, today).summary
assert summary.stock_qty == Decimal("47.000")
assert summary.stock_cost == Decimal("940.00") # 47 × 20
def test_singles_can_outlast_one_batch_and_spill_into_the_next(vault, cookies, today):
journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [pack_buy(cookies, 1, 200)]
)
second = journal.create_batch(
vault, today, today + timedelta(days=14), [pack_buy(cookies, 1, 200)]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(cookies, 14, 30)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_sold == Decimal("10.000")
assert report.batch_report(second.id).qty_sold == Decimal("4.000")
def test_pack_size_is_snapshotted(vault, cookies, today, in_two_weeks):
"""Переопределили пачку — уже записанные документы не должны поехать."""
journal.create_batch(vault, today, in_two_weeks, [pack_buy(cookies, 2, 200)])
journal.set_packs(vault, cookies.id, [{"name": "пачка", "size": 25, "retail_price": 500}])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_total == Decimal("20.000") # по 10, как и покупали
def test_bulk_pack_is_cheaper_per_piece(vault, today, in_two_weeks):
"""Коробка обходится дешевле поштучного — иначе смысла её брать нет.
Печенье поштучно 25 , коробка из 20 300 , то есть по 15 за штуку.
Продаём поштучно по 25: наценка идёт от 15, а не от 25.
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
product = vault.doc.product(product.id)
assert product.cost_for("коробка") == Decimal("300.00")
assert product.retail_for("коробка") == Decimal("500.00")
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": product.id, "qty": 1, "uom": "коробка", "unit_cost": 300}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(product, 20, 25)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("300.00") # долг пекарне — за коробку
assert batch.qty_total == Decimal("20.000")
assert batch.cash_collected == Decimal("500.00") # 20 × 25
assert report.summary.gross_margin == Decimal("200.00") # 20 × (25 15)
def test_mixed_pack_and_single_purchases_average_the_cost(vault, today, in_two_weeks):
"""В одной партии и коробка, и штуки — остаток по товару всё равно один.
Себестоимость базовой единицы становится средневзвешенной, иначе непонятно,
какая из двух цен списывается при поштучной продаже.
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
product = vault.doc.product(product.id)
journal.create_batch(
vault, today, in_two_weeks,
[
{"product_id": product.id, "qty": 1, "uom": "коробка", "unit_cost": 300},
{"product_id": product.id, "qty": 5, "uom": "шт", "unit_cost": 25},
],
)
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("425.00") # 300 + 125
assert batch.qty_total == Decimal("25.000") # 20 + 5
assert batch.stock_cost == Decimal("425.00") # 25 штук по 17 ₽ в среднем
Долг как явный выбор, история цен у фасовок Долг было непонятно как записать, и не зря. Оплата была просто денежным полем с полной суммой: долг получался, если догадаться стереть число и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что оплачено полностью, а любая правка строк подставляла полную сумму обратно и молча стирала выставленный долг. Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил часть. Пересчёт работает только в первом режиме. Поле суммы осталось живым — вписанное руками число само зажигает подходящий переключатель. Долг без имени записать нельзя: на экране долгов такая запись попадает в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром вводе это предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это был. На экране долгов человека можно выбрать целиком и рассчитаться сразу за всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых. Цены фасовок переехали в «Цены и история». Они меняются одним решением поставщика вместе с ценой товара, а история была только у товара — у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке товара цены фасовок теперь только показываются, правится там состав. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:25:49 +07:00
def test_pack_prices_change_through_history(vault, today):
"""Цена коробки меняется только там же, где цена штуки, — с историей.
Иначе у товара история есть, а у фасовки нет, хотя дорожают они обычно
одним решением поставщика.
"""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
journal.change_prices(
vault, product.id, 28, 30, today, "подорожала мука",
packs=[{"name": "коробка", "cost_price": 340, "retail_price": 560}],
)
product = vault.doc.product(product.id)
assert product.cost_for("коробка") == Decimal("340.00")
assert product.retail_for("коробка") == Decimal("560.00")
# Обе точки истории знают цены фасовки, включая стартовую.
assert [p.pack("коробка").cost_price for p in product.price_history] == [
Decimal("300.00"), Decimal("340.00")
]
def test_editing_pack_size_does_not_touch_its_prices(vault):
"""Правка состава фасовок — не правка цен, и молча их сбрасывать нельзя."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
# Карточка товара присылает нули: цены там показаны только для чтения.
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 24, "cost_price": 0, "retail_price": 0}],
)
pack = vault.doc.product(product.id).packs[0]
assert pack.size == Decimal("24.000")
assert pack.cost_price == Decimal("300.00")
assert pack.retail_price == Decimal("500.00")
def test_new_pack_price_lands_in_history(vault, today):
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.change_prices(vault, product.id, 26, 27, today, "первое подорожание")
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
product = vault.doc.product(product.id)
# Отдельной точкой: приписывать коробке цену задним числом было бы враньём.
assert "заведена фасовка «коробка»" in product.price_history[-1].note
assert product.price_history[-1].pack("коробка").cost_price == Decimal("300.00")
assert product.price_history[0].packs == []
def test_price_change_without_packs_keeps_them(vault, today):
"""packs=None — «фасовки не трогаем», но снимок всё равно полный."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
journal.change_prices(vault, product.id, 28, 30, today, "только штука")
product = vault.doc.product(product.id)
assert product.cost_for("коробка") == Decimal("300.00")
assert product.price_history[-1].pack("коробка").cost_price == Decimal("300.00")
def test_price_change_of_only_a_pack_is_recorded(vault, today):
"""Подорожала только коробка — это тоже изменение, а не пустая правка."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
before = len(vault.doc.product(product.id).price_history)
journal.change_prices(
vault, product.id, 25, 25, today, "коробка подорожала",
packs=[{"name": "коробка", "cost_price": 320, "retail_price": 500}],
)
product = vault.doc.product(product.id)
assert len(product.price_history) == before + 1
assert product.cost_for("коробка") == Decimal("320.00")
def test_unchanged_prices_add_nothing(vault, today):
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
journal.set_packs(
vault, product.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
before = len(vault.doc.product(product.id).price_history)
journal.change_prices(
vault, product.id, 25, 25, today, "ничего не менял",
packs=[{"name": "коробка", "cost_price": 300, "retail_price": 500}],
)
assert len(vault.doc.product(product.id).price_history) == before
def test_pack_without_its_own_cost_still_derives_it(vault, today, in_two_weeks):
"""Пустая цена фасовки — прежнее поведение, а не ноль."""
product = journal.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
journal.set_packs(vault, product.id, [{"name": "пачка", "size": 10}])
product = vault.doc.product(product.id)
assert product.cost_for("пачка") == Decimal("200.00") # 20 × 10
assert product.retail_for("пачка") == Decimal("300.00") # 30 × 10
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 1. База и код разъехались по разным репозиториям. Автокоммит и раньше трогал только vault.fmdb, но лежал он в той же ветке, что и исходники, поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный клон food-records со своим .git, а исходники его игнорируют целиком. При первом запуске приложение встаёт на уже существующую историю сервера, а не заводит параллельную. 2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO считаются в базовых единицах, поэтому поштучные продажи вычитаются из купленных пачек. Размер фасовки хранится в документе слепком. Миграция схемы 1→2 не меняет поведение уже заведённых данных. 3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы — отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя заводится как контрагент, повтор периода подсвечивается. Тесты больше не имеют настроенного remote, чтобы не ходить в сеть. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
"""200 на 3 не делится нацело — округление не должно копиться."""
product = journal.create_product(vault, "Пирожки", unit="шт", cost_price=0, retail_price=100)
journal.set_packs(vault, product.id, [{"name": "тройка", "size": 3, "retail_price": 200}])
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": product.id, "qty": 1, "uom": "тройка", "unit_cost": 200}],
)
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": product.id, "qty": 1, "uom": "тройка", "unit_price": 200}],
)
report = ledger.build(vault.doc, today)
assert report.batches[0].cash_collected == Decimal("200.00")
assert report.summary.gross_margin == 0
assert report.summary.stock_qty == 0
Пекарня может простить остаток Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт, но денег никто не отдавал. У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы суммы, которых не платил. Поэтому «Отдано» — только живые деньги, «Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике по периодам и по времени. В прибыль прощённое идёт целиком: эти деньги предназначались пекарне, а остались у тебя. «Осталось собрать» на ту же сумму уменьшается — собирать под прощённый остаток уже не надо. Простить больше долга нельзя: лишнее не засчитывается и, в отличие от переплаты деньгами, никуда не переливается. В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь сколько отдал, остаток уходит отдельной записью, партия закрывается сама, потому что её долг становится нулём. Миграция схемы 3→4 помечает всё записанное раньше как живые деньги. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
# --- Пекарня простила остаток --------------------------------------------
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
Чаевые Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы закрытой, когда за булки ещё должны. Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе поле дёргалось бы посреди набора. То же при приёме оплаты по долгу: заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется, как было раньше. В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят в выручку и не создают долга. Продажа, списанная с нескольких партий, делит чаевые между ними пропорционально выручке. По съеденному и подаренному чаевых не бывает — поле отключается. Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления этой фичи не заметит. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
# --- Чаевые ---------------------------------------------------------------
def test_tip_is_not_a_debt(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["мак"], 2, 35)], paid_amount=70, tip=30
)
assert sale.total == Decimal("70.00")
assert sale.paid == Decimal("70.00")
assert sale.tip == Decimal("30.00")
assert sale.received == Decimal("100.00")
assert sale.debt == 0
assert ledger.build(vault.doc, today).summary.receivable == 0
def test_overpayment_becomes_a_tip(vault, buns, today, in_two_weeks):
"""Булка 70, дали 100, сдачи не надо — 30 это чаевые."""
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
sale = journal.create_sale(
vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=100
)
assert sale.paid == Decimal("70.00")
assert sale.tip == Decimal("30.00")
assert sale.debt == 0
def test_tip_counts_toward_batch_coverage(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)], tip=30)
batch = ledger.build(vault.doc, today).batches[0]
assert batch.cost_total == Decimal("200.00")
assert batch.cash_collected == Decimal("100.00") # 70 за булки + 30 чаевых
assert batch.still_to_collect == Decimal("100.00")
assert batch.coverage_pct == Decimal("50.00")
def test_tip_goes_straight_into_profit(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)], tip=30)
summary = ledger.build(vault.doc, today).summary
assert summary.gross_margin == Decimal("30.00") # 2 × (35 20)
assert summary.tips == Decimal("30.00")
assert summary.profit == Decimal("60.00")
def test_tip_does_not_inflate_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_RETAIL, [line(buns["мак"], 2, 35)], tip=30)
summary = ledger.build(vault.doc, today).summary
assert summary.revenue == Decimal("70.00")
assert summary.cash_collected == Decimal("100.00")
def test_tip_survives_a_partially_paid_sale(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["мак"], 4, 35)], paid_amount=40, tip=25
)
assert sale.debt == Decimal("100.00")
batch = ledger.build(vault.doc, today).batches[0]
assert batch.cash_collected == Decimal("65.00") # 40 оплаты + 25 чаевых
def test_tip_splits_between_batches_by_revenue(vault, buns, today):
"""Продажа списалась с двух партий — чаевые делятся между ними."""
old = journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 3, 20)]
)
new = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 7, 20)]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)], tip=40)
report = ledger.build(vault.doc, today)
# Три штуки из старой, одна из новой — чаевые в тех же долях.
assert report.batch_report(old.id).cash_collected == Decimal("135.00") # 105 + 30
assert report.batch_report(new.id).cash_collected == Decimal("45.00") # 35 + 10
assert report.summary.cash_collected == Decimal("180.00")
def test_paying_more_than_the_debt_becomes_a_tip(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["мак"], 2, 35)], paid_amount=0
)
journal.add_sale_payment(vault, sale.id, 100)
assert sale.paid == Decimal("70.00")
assert sale.tip == Decimal("30.00")
assert sale.debt == 0
def test_consumption_never_gets_a_tip(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_SELF, [line(buns["мак"], 2, 0)], paid_amount=50, tip=50
)
assert sale.tip == 0
assert ledger.build(vault.doc, today).summary.tips == 0
with pytest.raises(journal.ValidationError, match="не бывает"):
journal.set_sale_tip(vault, sale.id, 100)
def test_tip_can_be_added_afterwards(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["мак"], 2, 35)])
journal.set_sale_tip(vault, sale.id, 15)
assert sale.tip == Decimal("15.00")
assert ledger.build(vault.doc, today).summary.tips == Decimal("15.00")
def test_tip_without_any_stock_still_counts_as_income(vault, buns, today):
"""Продали то, чего не закупали: чаевые получены и не должны потеряться.
Сама выручка при этом ни к какой партии не относится на это ругается
предупреждение о нехватке. Чаевые же считаются по продажам напрямую,
поэтому в прибыль они попадают в любом случае.
"""
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 1, 35)], tip=20)
report = ledger.build(vault.doc, today)
assert report.summary.tips == Decimal("20.00")
assert report.summary.profit == Decimal("20.00")
assert any("больше" in w for w in report.warnings)
def test_negative_tip_rejected(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.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 1, 35)], tip=-5)
def test_no_tips_changes_nothing(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["мак"], 4, 35)])
summary = ledger.build(vault.doc, today).summary
assert summary.tips == 0
assert summary.cash_collected == Decimal("140.00")
assert summary.profit == Decimal("60.00")
# --- Дробные количества: розлив и вес ------------------------------------
@pytest.fixture
def juice(vault):
"""Сок: берётся литрами, продаётся в том числе по пол-литра."""
return journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
def test_fractional_sale_leaves_fractional_stock(vault, juice, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_sold == Decimal("0.500")
assert batch.qty_left == Decimal("9.500")
assert batch.cash_collected == Decimal("60.00") # 0,5 × 120
assert report.summary.gross_margin == Decimal("20.00") # 0,5 × (120 80)
def test_fractional_purchase(vault, juice, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}]
)
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_total == Decimal("2.500")
assert report.batches[0].cost_total == Decimal("200.00") # 2,5 × 80
def test_many_fractional_sales_add_up_exactly(vault, juice, today, in_two_weeks):
"""Дробные не должны копить погрешность — потому и Decimal, а не float."""
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}]
)
for _ in range(20):
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.1", 120)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_sold == Decimal("2.000")
assert report.batches[0].qty_left == Decimal("8.000")
assert report.batches[0].cash_collected == Decimal("240.00")
def test_fractional_pack_size(vault, juice, today, in_two_weeks):
"""Бутылка 1,5 л — фасовка дробного размера."""
journal.set_packs(vault, juice.id, [{"name": "бутылка", "size": "1.5", "retail_price": 170}])
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": juice.id, "qty": 4, "uom": "бутылка", "unit_cost": 120}],
)
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": juice.id, "qty": 1, "uom": "бутылка", "unit_price": 170}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.qty_total == Decimal("6.000") # 4 бутылки × 1,5 л
assert batch.qty_sold == Decimal("2.000") # 1,5 + 0,5
assert batch.qty_left == Decimal("4.000")
assert batch.cash_collected == Decimal("230.00") # 170 + 60
def test_fractional_stock_value(vault, juice, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.25", 120)])
summary = ledger.build(vault.doc, today).summary
assert summary.stock_qty == Decimal("9.750")
assert summary.stock_cost == Decimal("780.00") # 9,75 × 80
def test_fractional_self_consumption(vault, juice, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 5, "unit_cost": 80}]
)
journal.create_sale(vault, today, KIND_SELF, [line(juice, "0.5", 0)])
summary = ledger.build(vault.doc, today).summary
assert summary.consumed_cost == Decimal("40.00") # 0,5 × 80
assert summary.profit == Decimal("-40.00")
def test_fractional_sale_spills_across_batches(vault, juice, today):
journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9),
[{"product_id": juice.id, "qty": "1.5", "unit_cost": 80}],
)
second = journal.create_batch(
vault, today, today + timedelta(days=14),
[{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "2.25", 120)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_sold == Decimal("1.500")
assert report.batch_report(second.id).qty_sold == Decimal("0.750")
assert report.batch_report(second.id).qty_left == Decimal("1.750")
def test_summary_counts_positions_because_units_differ(vault, juice, buns, today, in_two_weeks):
"""Литры, килограммы и штуки в одну сумму не складываются.
Сводка поэтому показывает деньги и число позиций, а не общее количество.
"""
cheese = journal.create_product(vault, "Сыр", unit="кг", cost_price=600, retail_price=900)
journal.create_batch(
vault, today, in_two_weeks,
[
{"product_id": juice.id, "qty": 10, "unit_cost": 80},
{"product_id": cheese.id, "qty": "3.5", "unit_cost": 600},
buy(buns["мак"], 20, 20),
],
)
summary = ledger.build(vault.doc, today).summary
assert summary.stock_positions == 3
assert summary.stock_cost == Decimal("3300.00") # 800 + 2100 + 400
def test_fractional_shortfall_is_reported(vault, juice, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "0.5", "unit_cost": 80}]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.75", 120)])
report = ledger.build(vault.doc, today)
assert [s.qty for s in report.shortfalls] == [Decimal("0.250")]
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 1. База и код разъехались по разным репозиториям. Автокоммит и раньше трогал только vault.fmdb, но лежал он в той же ветке, что и исходники, поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный клон food-records со своим .git, а исходники его игнорируют целиком. При первом запуске приложение встаёт на уже существующую историю сервера, а не заводит параллельную. 2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO считаются в базовых единицах, поэтому поштучные продажи вычитаются из купленных пачек. Размер фасовки хранится в документе слепком. Миграция схемы 1→2 не меняет поведение уже заведённых данных. 3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы — отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя заводится как контрагент, повтор периода подсвечивается. Тесты больше не имеют настроенного remote, чтобы не ходить в сеть. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
def test_product_without_packs_behaves_exactly_as_before(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["мак"], 4, 35)])
report = ledger.build(vault.doc, today)
assert report.batches[0].qty_left == Decimal("6.000")
assert report.batches[0].cash_collected == Decimal("140.00")
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")