# Булочная Десктопный учёт закупки и перепродажи булок. Вся база — один файл, зашифрованный паролем, который раз в час уезжает в отдельный репозиторий. **Два репозитория, и это принципиально:** | репозиторий | что внутри | |---|---| | `gt.ser.gay/kizya/food-market` | исходники программы (этот) | | `gt.ser.gay/kizya/food-records` | только `vault.fmdb` — зашифрованная база | Смешивать их нельзя: `git push` отправляет ветку целиком, поэтому в общем репозитории история данных неизбежно тащила бы за собой историю программы. На диске это папка `data/` рядом с exe — самостоятельный клон репозитория с данными, со своим `.git`. Исходники её полностью игнорируют. ## Что считает Булки берутся в пекарне **в долг**: к дедлайну надо вернуть себестоимость партии, иначе новых не напекут. Наценка остаётся тебе. Часть булок уходит друзьям по себестоимости, часть съедается — и за них пекарне тоже платить. Программа отвечает на главный вопрос: **успеваю ли я собрать себестоимость конкретной партии до её срока**. - **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться. - **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание. Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период. - **Долги** — кто сколько должен, с частичными оплатами. - **Товары** — номенклатура с фасовками и историей изменения цен. - **Статистика** — периоды, недели, месяцы, годы, разрезы по товарам и людям. - **Журнал** — что менялось в базе, когда и с какой машины. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. ### Статистика Шесть разрезов, у каждого график и таблица с итоговой строкой. | Разрез | Что показывает | |---|---| | **Периоды** | Каждая закупка — период со своим сроком. Долг, отдано, собрано, покрытие, прибыль, статус | | **Недели** | По ISO-неделям, понедельник–воскресенье | | **Месяцы** | Календарные месяцы | | **Годы** | Календарные годы | | **Товары** | Кто кормит, а кто лежит: наценка, наценка %, съедено, остаток | | **Люди** | Кто сколько взял, занёс, оставил чаевых и остался должен | Пустые корзины между непустыми не создаются: если в марте не торговали, строки за март не будет. Дырка честнее выдуманного нуля. **База расчёта.** Смешивать «когда продал» и «когда получил деньги» нельзя — это разные вопросы, и ответы расходятся, как только появляется хоть один долг. Поэтому в таблице два основания одновременно: - по дате **продажи** — выручка, себестоимость, наценка, съеденное, чаевые. Это «сколько наторговал за месяц»; - по дате **платежа** — колонка «Собрано». Это «сколько денег реально пришло», и долг, возвращённый в сентябре, попадёт в сентябрь, а не в августовскую продажу; - по дате **закупки** — «Закуплено», по дате расчёта с пекарней — «Отдано пекарне» и «Прощено». У разреза «Периоды» основание третье: там всё привязано к самой партии, потому что вопрос стоит иначе — «покрыл ли я эту партию к сроку». Общее количество нигде не показывается одним числом — только по единицам («1,5 л · 4 шт»). Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет. График рисуется на QPainter, а не на QtCharts: та тянет за собой десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Если корзин больше 24, на графике показываются последние, и об этом написано прямо над ним — молча показанная часть выглядела бы как всё. ### Пекарня простила остаток Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг при этом закрыт, но денег никто не отдавал. В диалоге «Платёж пекарне» есть галочка **«Остаток простили — закрыть партию»**: вводишь, сколько реально отдал, и остаток уходит отдельной записью вида «скидка пекарни». Партия закрывается сама — её долг становится нулём. Скидка хранится отдельно от платежей намеренно. Свали их в одну кучу — и «Отдано пекарне» в статистике покажет суммы, которых ты не платил. Поэтому: - **«Отдано»** — только живые деньги; - **«Прощено»** — закрывает долг, но кармана не покидает; - в прибыль прощённое идёт **целиком**: эти деньги предназначались пекарне, а остались у тебя; - «осталось собрать» уменьшается — под прощённый остаток собирать уже не надо. Простить больше, чем должен, нельзя: лишнее не засчитывается и никуда не переливается. ### Чаевые Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы закрытой, когда за булки ещё должны. Если вводишь оплату больше суммы — булка 70, дали 100 — программа сама предложит записать разницу в чаевые. То же самое при приёме оплаты по долгу: заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется. В расчётах чаевые: - **идут в покрытие партии** — это живые деньги, ими так же рассчитываются с пекарней. Если продажа списалась с нескольких партий, чаевые делятся между ними пропорционально выручке; - **добавляются в прибыль целиком** — себестоимости за ними нет; - **не входят в выручку и не создают долга** — за них никто ничего не должен. По съеденному, подаренному и списанному чаевых не бывает — поле отключается. ### Дробные количества Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра или полбулки учитываются как есть. Единица измерения у товара произвольная — `шт`, `л`, `кг`. Поля ввода принимают и запятую, и точку: на цифровой клавиатуре точка, а интерфейс русский и ждёт запятую, так что точка молча заменяется на запятую. Без этого символ просто не появлялся бы в поле. Общее количество остатка по всем товарам сразу нигде не показывается — складывать литры с килограммами бессмысленно. В сводке остаток выражен деньгами и числом позиций, а разбивка по товарам есть в таблице «Остатки». ### Фасовки Печенье поштучно за 25 ₽ и коробка того же печенья на 20 штук — это **один товар с двумя фасовками**, а не два разных товара. У товара есть базовая единица (штука) и любое число фасовок со своим размером и своими ценами. Остатки, себестоимость и FIFO всегда считаются в базовых единицах, поэтому поштучные продажи корректно вычитаются из купленных коробок. Количество и цена при этом вводятся в той фасовке, которую выбрал ты: «2 коробки по 300 ₽» так и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь коробку с 20 на 24 штуки, и уже записанные документы не поедут. **У фасовки обе цены свои и обе за упаковку целиком.** Коробка из 20 штук, которые внутри выходят по 15 ₽, стоит в закупке 300 ₽ — так и указывается. Выводить эту сумму из поштучной цены нельзя: получилось бы, что опт стоит столько же, сколько розница, и в каждой закупке сумму приходилось бы исправлять руками. Чтобы не держать пересчёт в голове, карточка товара расшифровывает результат прямо под таблицей фасовок: ``` коробка = 20 шт · закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт) ``` Цена вводится за упаковку, а думает человек о ней поштучно — «коробка из 20, они там по 15». Показанные тут же 15 ₽ ловят ошибку в двадцать раз сразу, а не в закупке. Оставленный ноль означает «своей цены нет, считай от цены за одну штуку» — так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры в старых базах не поехали. Если один товар попал в партию и коробками, и поштучно, себестоимость базовой единицы становится средневзвешенной: остаток по товару всё равно один, и при поштучной продаже иначе было бы непонятно, какая из двух цен списывается. ### Ввод истории Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат, каждая строка таблицы — отдельная продажа. Дата новой строки наследуется от предыдущей, Enter добавляет строку, Ctrl+D дублирует, Ctrl+Enter записывает всё разом. Незнакомое имя в колонке «Кому» заводится как новый контрагент. Программа подсказывает, сколько продаж за выбранный период уже записано — чтобы не внести один и тот же месяц дважды. Продажи разносятся по партиям методом FIFO. Цены у поставщика фиксированные, поэтому на себестоимость это не влияет — FIFO нужен только чтобы понимать, деньги за какую партию уже пришли. Прибыль считается как наценка с проданного **плюс** чаевые **плюс** прощённое пекарней **минус** себестоимость съеденного и подаренного: за съеденное пекарне платить всё равно, и покрывается это из маржи. ## Как запустить ```bash pip install -r requirements.txt python run.py ``` Собрать один exe: ```bash pip install -r requirements-dev.txt pyinstaller food-market.spec ``` Готовый `dist/food-market.exe` (~50 МБ) можно положить куда угодно — базу он ищет в папке `data/` рядом с собой и при первом запуске сам заведёт там клон репозитория с данными. На новой машине быстрее склонировать данные сразу: ```bash git clone https://gt.ser.gay/kizya/food-records.git data ``` ## Хранение и шифрование `data/vault.fmdb` — единственный файл с данными: ``` JSON → gzip → AES-256-GCM (ключ: scrypt от пароля) → файл ``` Заголовок файла идёт в GCM как AAD, так что подменить соль или параметры KDF незаметно нельзя. Запись атомарная, предыдущая версия остаётся в `.bak`. **Забытый пароль восстановить невозможно.** В настройках есть кнопка выгрузки открытого JSON — держи такую копию отдельно. В ней лежит и токен GitLab. ## Синхронизация Раз в час (интервал настраивается) приложение сохраняет базу, коммитит **только** `vault.fmdb` и пушит — в репозиторий данных, не в этот. Что бы ни оказалось в папке рядом с базой, в коммит оно не попадёт: все команды идут с явным списком путей. Токен GitLab хранится внутри зашифрованной базы и подставляется в URL только на время вызова — в `.git/config` он не пишется и в журнал не попадает. ### Один коммит на день Дельта-сжатие на шифртексте не работает: каждый коммит хранит полную копию файла. При часовых пушах это ~1800 коммитов и порядка 60 МБ в год. Поэтому в течение суток приложение дописывает в ту же вершину через `--amend` — выходит около 13 МБ в год. Форс-пуш делается только с `--force-with-lease` и только когда все условия выполнены: вершина имеет наш формат сообщения, датирована сегодня, трогает ровно `data/vault.fmdb` и совпадает с тем, что на сервере. Иначе — обычный коммит без переписывания истории. Плата за схлопывание: откатиться через git можно на границу суток, а не на любой час. Детализацию внутри дня даёт журнал. ### Работа с двух машин Слить два зашифрованных блоба автоматически невозможно. Если версии разошлись, автопуш останавливается, версия с сервера кладётся рядом как `data/vault.remote.fmdb`, а выбор предлагается в настройках. Ничего не затирается молча. ## Разработка ```bash pytest -q ``` Модули: | файл | зачем | |---|---| | `app/crypto.py` | формат файла, scrypt + AES-GCM | | `app/models.py` | структура документа, фасовки, деньги в Decimal | | `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» | | `app/journal.py` | **единственный путь записи** в базу + аудит-лог | | `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки | | `app/stats.py` | сводные цифры по периодам, товарам и людям | | `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений | | `app/paths.py` | где лежат данные и почему отдельно от кода | | `app/ui/quick_sales.py` | быстрый ввод продаж за период | | `app/ui/chart.py` | столбчатый график на QPainter | | `app/ui/` | остальные экраны на PySide6 | Главное архитектурное правило: **ни один экран не меняет документ напрямую**. Всё идёт через функции `app/journal.py`, которые сами считают разницу и дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать изменение, и заметили бы это только когда история понадобится.