Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке — ради этого её и берут. Своей была только цена продажи, а себестоимость выводилась умножением поштучной цены на размер фасовки, то есть опт считался по цене розницы. В каждой закупке сумму приходилось править руками. Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает «своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры в уже заведённых базах не поехали. Карточка товара расшифровывает результат построчно: «коробка = 20 шт · закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)». Цена вводится за упаковку, а думает человек о ней поштучно, и без пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
20 KiB
Markdown
282 lines
20 KiB
Markdown
# Булочная
|
||
|
||
Десктопный учёт закупки и перепродажи булок. Вся база — один файл,
|
||
зашифрованный паролем, который раз в час уезжает в отдельный репозиторий.
|
||
|
||
**Два репозитория, и это принципиально:**
|
||
|
||
| репозиторий | что внутри |
|
||
|---|---|
|
||
| `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`, которые сами считают разницу и
|
||
дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать
|
||
изменение, и заметили бы это только когда история понадобится.
|