food-market/README.md
food-market d08d2421f7 Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.

Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.

Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.

На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.

Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 00:25:49 +03:00

363 lines
26 KiB
Markdown
Raw Permalink 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, на графике показываются последние, и об этом написано прямо
над ним — молча показанная часть выглядела бы как всё.
### Взял в долг
В форме продажи выбор рассчёта явный, тремя переключателями:
| | |
|---|---|
| **Заплатил полностью** | по умолчанию, сумма подставляется сама |
| **Взял в долг целиком** | получено ноль, вся сумма уходит в долг |
| **Заплатил часть** | сколько дал — в поле, остаток в долг |
Раньше это было просто денежное поле, заполненное полной суммой: долг
получался, если догадаться стереть число и вписать ноль. Хуже того, любая
последующая правка строк подставляла полную оплату обратно — выставленный долг
исчезал молча, а подсказка продолжала уверять, что оплачено полностью.
Теперь пересчёт работает только в режиме «заплатил полностью».
Поле суммы остаётся живым: вписал число руками — загорится подходящий
переключатель. Ноль включает «в долг целиком», часть суммы — «заплатил часть»,
больше суммы — разница уходит в чаевые.
**Долг без имени записать нельзя.** Такая запись невосстановима: на экране
долгов она попадает в кучу «без контрагента», и с кого спрашивать деньги —
уже не узнать. Форма продажи не выпустит такую запись и подсветит подсказку;
контрагента можно завести кнопкой рядом с полем «Кому». В быстром вводе это
предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это
был.
На экране долгов можно выбрать **человека целиком** и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, начиная со
старых. Отдал больше, чем был должен, — излишек становится чаевыми, как и
переплата по одной продаже. Выбранная отдельная продажа по-прежнему гасится
сама по себе.
### Отбор продаж
Список продаж растёт быстрее всех остальных, поэтому у него своя панель
отбора: поиск, период, тип, человек и состояние оплаты. Всё складывается —
«за прошлый месяц Васе в долг» набирается тремя щелчками.
| Фильтр | Что умеет |
|---|---|
| **Поиск** | По товару, имени, заметке и названию типа |
| **Период** | Сегодня, неделя, месяц, прошлый месяц, 30 дней, год, свой диапазон |
| **Тип** | Розница, другу, съел сам, подарок, списание |
| **Кому** | Конкретный человек либо «без контрагента» |
| **Оплата** | С долгом, без долга, с чаевыми |
Слова в поиске ищутся по отдельности: «вася мак» находит булки с маком,
проданные Васе, хотя подряд эти слова нигде не написаны.
Поля своего диапазона появляются, только когда он выбран, — иначе две даты
занимали бы место в панели всё остальное время. Кнопка «Сбросить» гаснет,
когда сбрасывать нечего.
Под таблицей — итоги **ровно по показанному**: сколько продаж, на какую сумму,
сколько оплачено, чаевых и долга. Половина смысла фильтров в этой строке:
отобрал по человеку — сразу видно, сколько он взял и сколько за ним осталось.
Сортировка — щелчком по заголовку любой колонки, выбор переживает
перерисовку списка. По умолчанию сверху свежие.
Даты сортируются по времени, а не по тексту. Отформатированная дата — обычная
строка, и по алфавиту она идёт по дню месяца: 01.12.2025 оказывается раньше
02.01.2020. Сортировка при этом выглядит рабочей, просто выдаёт бессмыслицу,
поэтому в ячейке рядом с текстом лежит настоящее значение.
### Пекарня простила остаток
Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 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/salesfilter.py` | правила отбора продаж: период, поиск, оплата |
| `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений |
| `app/paths.py` | где лежат данные и почему отдельно от кода |
| `app/ui/quick_sales.py` | быстрый ввод продаж за период |
| `app/ui/chart.py` | столбчатый график на QPainter |
| `app/ui/` | остальные экраны на PySide6 |
Главное архитектурное правило: **ни один экран не меняет документ напрямую**.
Всё идёт через функции `app/journal.py`, которые сами считают разницу и
дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать
изменение, и заметили бы это только когда история понадобится.