food-market/README.md
food-market 950ccc33b3 Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.

Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.

Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:46:55 +03:00

282 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Булочная
Десктопный учёт закупки и перепродажи булок. Вся база — один файл,
зашифрованный паролем, который раз в час уезжает в отдельный репозиторий.
**Два репозитория, и это принципиально:**
| репозиторий | что внутри |
|---|---|
| `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`, которые сами считают разницу и
дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать
изменение, и заметили бы это только когда история понадобится.