Десктопное приложение на PySide6. Вся база — один JSON-документ, зашифрованный AES-256-GCM под паролем (ключ через scrypt), лежит в data/vault.fmdb и раз в час уезжает в этот репозиторий. Основное: - партии с дедлайном возврата себестоимости пекарне, FIFO-разнос продаж по партиям и прогресс покрытия к сроку; - типы выбытия: розница, другу по себестоимости, съел сам, подарок, списание — съеденное вычитается из прибыли, за него платить всё равно; - долги контрагентов с частичными оплатами; - номенклатура с историей изменения цен; - журнал изменений внутри базы: git хранит непрозрачные снимки, поэтому настоящая история ведётся здесь. Синхронизация коммитит только путь базы и схлопывает часовые пуши в один коммит на день через amend + force-with-lease. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
268 lines
9.7 KiB
Python
268 lines
9.7 KiB
Python
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
|