2026-08-22 21:29:53 +07:00
|
|
|
|
"""Загрузка, сохранение и версионирование схемы базы.
|
|
|
|
|
|
|
|
|
|
|
|
Ключевая деталь — определение «изменилось ли что-нибудь». Шифрование
|
|
|
|
|
|
недетерминировано, поэтому сравнивать байты файла бессмысленно: одни и те же
|
|
|
|
|
|
данные каждый раз дают разный шифртекст. Вместо этого храним 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
|
|
|
|
|
|
|
|
|
|
|
|
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
def _migrate_4_to_5(raw: dict) -> dict:
|
|
|
|
|
|
"""У фасовки появилась своя закупочная цена.
|
|
|
|
|
|
|
|
|
|
|
|
Раньше себестоимость фасовки выводилась умножением поштучной цены на её
|
|
|
|
|
|
размер, то есть опт стоил ровно столько же, сколько розница. Ноль здесь
|
|
|
|
|
|
означает «своей цены нет, считай по-старому», поэтому цифры уже заведённых
|
|
|
|
|
|
товаров не меняются: пока цену не проставят руками, всё работает как было.
|
|
|
|
|
|
"""
|
|
|
|
|
|
for product in raw.get("products", []):
|
|
|
|
|
|
for pack in product.get("packs", []):
|
|
|
|
|
|
pack.setdefault("cost_price", "0")
|
|
|
|
|
|
|
|
|
|
|
|
raw["schema_version"] = 5
|
|
|
|
|
|
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
|
|
|
|
_MIGRATIONS = {
|
|
|
|
|
|
0: _migrate_0_to_1,
|
|
|
|
|
|
1: _migrate_1_to_2,
|
|
|
|
|
|
2: _migrate_2_to_3,
|
|
|
|
|
|
3: _migrate_3_to_4,
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
4: _migrate_4_to_5,
|
Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.
У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.
В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.
В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.
Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
|
|
|
|
}
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|