food-market/app/storage.py

247 lines
9.7 KiB
Python
Raw Normal View History

"""Загрузка, сохранение и версионирование схемы базы.
Ключевая деталь определение «изменилось ли что-нибудь». Шифрование
недетерминировано, поэтому сравнивать байты файла бессмысленно: одни и те же
данные каждый раз дают разный шифртекст. Вместо этого храним sha256 от
канонического (отсортированного, без пробелов) открытого JSON. Без этого git
получал бы новый коммит на каждое срабатывание автосохранения.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
from pathlib import Path
from . import crypto
from .models import SCHEMA_VERSION, Document
def canonical_bytes(raw: dict) -> bytes:
"""Байты, по которым считается хеш и которые уходят в шифрование.
sort_keys обязателен: без него порядок ключей мог бы плавать и создавать
«изменения» на ровном месте.
"""
return json.dumps(
raw, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
def content_hash(raw: dict) -> str:
return hashlib.sha256(canonical_bytes(raw)).hexdigest()
# --- Миграции схемы -------------------------------------------------------
#
# Каждая функция получает сырой словарь версии N и возвращает версию N+1.
# Нужны потому, что база уже лежит в git: старый файл должен открываться
# новой программой без ручных плясок.
def _migrate_0_to_1(raw: dict) -> dict:
raw["schema_version"] = 1
raw.setdefault("journal", [])
return raw
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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 _migrate_1_to_2(raw: dict) -> dict:
"""Появились фасовки товара.
Всё, что заведено раньше, считается записанным в базовых единицах:
фасовка пустая, множитель единица. Поведение старых документов при этом
не меняется ни на копейку.
"""
for product in raw.get("products", []):
product.setdefault("packs", [])
for document, key in (
*((b, "lines") for b in raw.get("batches", [])),
*((s, "lines") for s in raw.get("sales", [])),
):
for line in document.get(key, []):
line.setdefault("uom", "")
line.setdefault("uom_size", "1")
raw["schema_version"] = 2
return raw
Чаевые Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы закрытой, когда за булки ещё должны. Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе поле дёргалось бы посреди набора. То же при приёме оплаты по долгу: заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется, как было раньше. В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят в выручку и не создают долга. Продажа, списанная с нескольких партий, делит чаевые между ними пропорционально выручке. По съеденному и подаренному чаевых не бывает — поле отключается. Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления этой фичи не заметит. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
def _migrate_2_to_3(raw: dict) -> dict:
"""Появились чаевые.
У всего, что записано раньше, их просто не было. Ноль не меняет ни одну
цифру в отчётах.
"""
for sale in raw.get("sales", []):
sale.setdefault("tip", "0")
raw["schema_version"] = 3
return raw
Пекарня может простить остаток Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт, но денег никто не отдавал. У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы суммы, которых не платил. Поэтому «Отдано» — только живые деньги, «Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике по периодам и по времени. В прибыль прощённое идёт целиком: эти деньги предназначались пекарне, а остались у тебя. «Осталось собрать» на ту же сумму уменьшается — собирать под прощённый остаток уже не надо. Простить больше долга нельзя: лишнее не засчитывается и, в отличие от переплаты деньгами, никуда не переливается. В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь сколько отдал, остаток уходит отдельной записью, партия закрывается сама, потому что её долг становится нулём. Миграция схемы 3→4 помечает всё записанное раньше как живые деньги. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
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:
version = int(raw.get("schema_version", 0))
if version > SCHEMA_VERSION:
raise crypto.UnsupportedFormat(
f"База сохранена более новой версией программы (схема {version}). "
"Обнови приложение, иначе данные можно потерять."
)
while version < SCHEMA_VERSION:
migration = _MIGRATIONS.get(version)
if migration is None:
raise crypto.VaultError(f"Нет миграции со схемы {version}.")
raw = migration(raw)
version = int(raw["schema_version"])
return raw
class Vault:
"""Открытая база: документ в памяти плюс всё, что нужно для записи."""
def __init__(self, path: Path, password: str, doc: Document, saved_hash: str = ""):
self.path = Path(path)
self._password = password
self.doc = doc
self._saved_hash = saved_hash
# Растёт при каждой мутации. UI смотрит на него, чтобы дёшево понять,
# что пора перерисоваться и запустить отложенное автосохранение.
self.revision = 0
# --- открытие и создание ---
@classmethod
def create(cls, path: Path, password: str) -> "Vault":
path = Path(path)
if path.exists():
raise crypto.VaultError(f"Файл {path} уже существует.")
path.parent.mkdir(parents=True, exist_ok=True)
vault = cls(path, password, Document())
vault.save(force=True)
return vault
@classmethod
def open(cls, path: Path, password: str) -> "Vault":
path = Path(path)
blob = path.read_bytes()
plaintext = crypto.decrypt(blob, password)
try:
raw = json.loads(plaintext.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise crypto.VaultError(
"База расшифровалась, но её содержимое повреждено."
) from exc
raw = migrate(raw)
doc = Document.from_dict(raw)
# Хеш считаем от того, что получилось после разбора и миграции, а не от
# прочитанного сырья: иначе миграция или нормализация Decimal сразу же
# выглядели бы как несохранённое изменение.
return cls(path, password, doc, saved_hash=content_hash(doc.to_dict()))
# --- состояние ---
def touch(self) -> None:
"""Отметить, что документ изменили. Зовётся из journal.apply."""
self.revision += 1
@property
def is_dirty(self) -> bool:
return content_hash(self.doc.to_dict()) != self._saved_hash
# --- запись ---
def save(self, force: bool = False) -> bool:
"""Записать базу на диск. Возвращает True, только если реально писали.
Именно этот возврат решает, будет ли git-коммит: если открытые данные
не изменились, файл не трогаем и коммита не будет.
"""
raw = self.doc.to_dict()
digest = content_hash(raw)
if not force and digest == self._saved_hash:
return False
blob = crypto.encrypt(canonical_bytes(raw), self._password)
_atomic_write(self.path, blob)
self._saved_hash = digest
return True
def reload(self) -> None:
"""Перечитать файл тем же паролем.
Нужно после того, как файл на диске подменил git: подтянули изменения
с другой машины или откатились на серверную версию.
"""
fresh = Vault.open(self.path, self._password)
self.doc = fresh.doc
self._saved_hash = fresh._saved_hash
self.revision += 1
def change_password(self, new_password: str) -> None:
self._password = new_password
self.save(force=True)
def check_password(self, password: str) -> bool:
return password == self._password
def export_plain_json(self, path: Path) -> Path:
"""Выгрузить базу в открытый JSON.
Единственная страховка от забытого пароля, поэтому она есть в UI явной
кнопкой. Файл содержит и git-токен его место не в общей папке.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(self.doc.to_dict(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
return path
def _atomic_write(path: Path, blob: bytes) -> None:
"""Записать файл так, чтобы обрыв питания не оставил его наполовину.
Пишем во временный файл, сбрасываем на диск, только потом подменяем
боевой. Предыдущая версия уезжает в .bak дешёвая страховка от того, что
свежий файл окажется битым.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "wb") as fh:
fh.write(blob)
fh.flush()
os.fsync(fh.fileno())
if path.exists():
try:
shutil.copy2(path, path.with_suffix(path.suffix + ".bak"))
except OSError:
# Бэкап — приятный бонус, но не повод не сохранить данные.
pass
os.replace(tmp, path)