food-market/tests/test_journal.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

268 lines
9.7 KiB
Python
Raw Permalink 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.

import json
from datetime import date, timedelta
import pytest
from app import journal
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF
def actions(vault):
return [e.action for e in vault.doc.journal]
def last(vault):
return vault.doc.journal[-1]
def changed(entry):
return {c.field: (c.old, c.new) for c in entry.changes}
# --- запись вообще происходит ---------------------------------------------
def test_create_product_is_logged(vault):
journal.create_product(vault, "Булка с маком", cost_price=20, retail_price=35)
entry = last(vault)
assert entry.action == "product.create"
assert entry.label == "Булка с маком"
assert changed(entry)["себестоимость"][1] == "20,00 ₽"
def test_price_change_records_old_and_new(vault, buns):
journal.change_prices(vault, buns["мак"].id, cost_price=25, retail_price=40)
entry = last(vault)
assert entry.action == "product.price_change"
assert changed(entry)["себестоимость"] == ("20,00 ₽", "25,00 ₽")
assert changed(entry)["цена продажи"] == ("35,00 ₽", "40,00 ₽")
def test_price_change_appends_to_history(vault, buns):
product = buns["мак"]
assert len(product.price_history) == 1 # точка при создании
journal.change_prices(vault, product.id, cost_price=25, retail_price=40)
assert len(product.price_history) == 2
assert product.price_history[-1].cost_price == 25
def test_every_operation_touches_the_vault(vault, buns, today, in_two_weeks):
"""revision растёт на каждой мутации — на него опирается автосохранение."""
before = vault.revision
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
)
assert vault.revision > before
def test_journal_covers_the_full_lifecycle(vault, buns, today, in_two_weeks):
batch = journal.create_batch(
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
)
sale = journal.create_sale(
vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}],
paid_amount=0,
)
journal.add_sale_payment(vault, sale.id, 70)
journal.add_bakery_payment(vault, 200, batch_id=batch.id)
journal.delete_sale(vault, sale.id)
assert actions(vault)[-5:] == [
"batch.create",
"sale.create",
"sale.payment_add",
"bakery_payment.add",
"sale.delete",
]
# --- пустые правки не создают шума ----------------------------------------
def test_noop_update_writes_nothing(vault, buns):
count = len(vault.doc.journal)
journal.update_product(vault, buns["мак"].id, name="Булка с маком")
assert len(vault.doc.journal) == count
def test_noop_price_change_writes_nothing(vault, buns):
count = len(vault.doc.journal)
journal.change_prices(vault, buns["мак"].id, cost_price=20, retail_price=35)
assert len(vault.doc.journal) == count
assert len(buns["мак"].price_history) == 1
def test_noop_edit_leaves_vault_clean(vault, buns):
"""Иначе открыл-закрыл форму — и в git уехал пустой коммит."""
vault.save()
journal.update_product(vault, buns["мак"].id, note="")
assert vault.is_dirty is False
# --- секреты не утекают в журнал ------------------------------------------
def test_token_value_never_reaches_the_journal(vault):
secret = "glpat-СУПЕРСЕКРЕТ12345"
journal.update_settings(vault, git={"token": secret})
entry = last(vault)
assert entry.action == "settings.update"
assert changed(entry)["git.token"] == ("скрыто", "изменён")
# Токен обязан быть в настройках, но нигде в журнале — включая открытый экспорт.
assert vault.doc.settings.git.token == secret
dumped = json.dumps([e.to_dict() for e in vault.doc.journal], ensure_ascii=False)
assert secret not in dumped
def test_non_secret_git_settings_are_logged_plainly(vault):
journal.update_settings(vault, git={"remote_url": "https://gt.ser.gay/kizya/food-market.git"})
assert "gt.ser.gay" in changed(last(vault))["git.remote_url"][1]
def test_settings_keep_types(vault):
journal.update_settings(vault, git={"auto_push_minutes": "30", "enabled": False})
assert vault.doc.settings.git.auto_push_minutes == 30
assert vault.doc.settings.git.enabled is False
# --- проверки корректности ------------------------------------------------
def test_product_in_use_cannot_be_deleted(vault, buns, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
)
with pytest.raises(journal.ValidationError, match="архив"):
journal.delete_product(vault, buns["мак"].id)
def test_unused_product_can_be_deleted(vault, buns):
journal.delete_product(vault, buns["мак"].id)
assert vault.doc.product(buns["мак"].id) is None
assert last(vault).action == "product.delete"
def test_batch_due_date_must_not_precede_purchase(vault, buns, today):
with pytest.raises(journal.ValidationError, match="раньше"):
journal.create_batch(
vault,
today,
today - timedelta(days=1),
[{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}],
)
def test_empty_batch_rejected(vault, today, in_two_weeks):
with pytest.raises(journal.ValidationError, match="хотя бы одна"):
journal.create_batch(vault, today, in_two_weeks, [])
def test_zero_quantity_rejected(vault, buns, today, in_two_weeks):
with pytest.raises(journal.ValidationError, match="больше нуля"):
journal.create_batch(
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 0, "unit_cost": 20}]
)
def test_counterparty_with_sales_cannot_be_deleted(vault, buns, today):
vasya = journal.create_counterparty(vault, "Вася")
journal.create_sale(
vault, today, KIND_FRIEND,
[{"product_id": buns["мак"].id, "qty": 1, "unit_price": 20}],
counterparty_id=vasya.id,
)
with pytest.raises(journal.ValidationError, match="удалить нельзя"):
journal.delete_counterparty(vault, vasya.id)
def test_batch_numbers_increment(vault, buns, today, in_two_weeks):
lines = [{"product_id": buns["мак"].id, "qty": 1, "unit_cost": 20}]
first = journal.create_batch(vault, today, in_two_weeks, lines)
second = journal.create_batch(vault, today, in_two_weeks, lines)
assert (first.number, second.number) == (1, 2)
# --- поведение вокруг оплат -----------------------------------------------
def test_sale_is_paid_in_full_by_default(vault, buns, today):
sale = journal.create_sale(
vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 35}]
)
assert sale.paid == sale.total == 70
assert sale.debt == 0
def test_sale_on_credit_creates_debt(vault, buns, today):
sale = journal.create_sale(
vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}],
paid_amount=0,
)
assert sale.paid == 0
assert sale.debt == 100
journal.add_sale_payment(vault, sale.id, 60)
assert sale.debt == 40
def test_consumption_never_records_payment(vault, buns, today):
"""Съел сам — денег нет по определению, даже если форма что-то прислала."""
sale = journal.create_sale(
vault, today, KIND_SELF, [{"product_id": buns["мак"].id, "qty": 2, "unit_price": 0}],
paid_amount=999,
)
assert sale.payments == []
assert sale.debt == 0
def test_overpayment_does_not_create_negative_debt(vault, buns, today):
sale = journal.create_sale(
vault, today, KIND_RETAIL, [{"product_id": buns["мак"].id, "qty": 1, "unit_price": 35}],
paid_amount=0,
)
journal.add_sale_payment(vault, sale.id, 100)
assert sale.debt == 0
# --- удаление партии не теряет деньги -------------------------------------
def test_deleting_batch_keeps_bakery_payments(vault, buns, today, in_two_weeks):
"""Деньги пекарне реально ушли — они не должны исчезнуть вместе с партией."""
batch = journal.create_batch(
vault, today, in_two_weeks, [{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20}]
)
payment = journal.add_bakery_payment(vault, 200, batch_id=batch.id)
journal.delete_batch(vault, batch.id)
assert payment in vault.doc.bakery_payments
assert payment.batch_id is None
# --- обслуживание ---------------------------------------------------------
def test_prune_journal(vault, buns):
for entry in vault.doc.journal:
entry.ts = "2020-01-01T00:00:00+03:00"
removed = journal.prune_journal(vault, date(2024, 1, 1))
assert removed == 3
assert [e.action for e in vault.doc.journal] == ["journal.prune"]
def test_prune_nothing_to_remove_is_silent(vault, buns):
count = len(vault.doc.journal)
assert journal.prune_journal(vault, date(2000, 1, 1)) == 0
assert len(vault.doc.journal) == count
def test_journal_entries_survive_save_and_reload(vault, buns):
from app.storage import Vault
vault.save()
reopened = Vault.open(vault.path, "pw")
assert [e.action for e in reopened.doc.journal] == ["product.create"] * 3
assert reopened.doc.journal[0].host