food-market/README.md
Claude 26b401e507 Цена по фасовке не подставлялась после удаления строки
Обработчики строк запоминали номер строки в момент создания. После
удаления любой строки всё, что было ниже, съезжает вверх, и запомненный
номер начинает указывать на соседа или за пределы таблицы. Строка молча
переставала работать целиком: выбираешь фасовку — цена не меняется,
меняешь товар — не пересобирается список фасовок. Приходилось править
цену руками.

Теперь обработчики привязаны к самому виджету, а строка ищется по нему
в момент вызова. То же исправлено в быстром вводе, где строк больше
и удаляют их чаще.

Заодно найдена мина, которую посадил я сам в разделе статистики:
self.metric = QComboBox() на QWidget затеняет метод QWidget.metric(),
который Qt зовёт при смене стиля. Падало это в чужом месте и с невнятным
«object is not callable». Такая же история была с self.layout в сводке.
Оба переименованы, добавлен тест, который обходит все виджеты и проверяет,
что ни один атрибут не затеняет метод Qt.

И подсказка в карточке товара: себестоимость фасовки нигде не вводится,
она выводится из цены базовой единицы. Теперь под таблицей фасовок прямо
написано, какая сумма подставится в закупку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:50:42 +03:00

240 lines
17 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, на графике показываются последние, и об этом написано прямо
над ним — молча показанная часть выглядела бы как всё.
### Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела бы
закрытой, когда за булки ещё должны.
Если вводишь оплату больше суммы — булка 70, дали 100 — программа сама
предложит записать разницу в чаевые. То же самое при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не теряется.
В расчётах чаевые:
- **идут в покрытие партии** — это живые деньги, ими так же рассчитываются
с пекарней. Если продажа списалась с нескольких партий, чаевые делятся
между ними пропорционально выручке;
- **добавляются в прибыль целиком** — себестоимости за ними нет;
- **не входят в выручку и не создают долга** — за них никто ничего не должен.
По съеденному, подаренному и списанному чаевых не бывает — поле отключается.
### Дробные количества
Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра
или полбулки учитываются как есть. Единица измерения у товара произвольная —
`шт`, `л`, `кг`.
Поля ввода принимают и запятую, и точку: на цифровой клавиатуре точка, а
интерфейс русский и ждёт запятую, так что точка молча заменяется на запятую.
Без этого символ просто не появлялся бы в поле.
Общее количество остатка по всем товарам сразу нигде не показывается —
складывать литры с килограммами бессмысленно. В сводке остаток выражен деньгами
и числом позиций, а разбивка по товарам есть в таблице «Остатки».
### Фасовки
Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — это **один
товар с двумя фасовками**, а не два разных товара. У товара есть базовая
единица (штука) и любое число фасовок со своим размером и своей ценой.
Остатки, себестоимость и FIFO всегда считаются в базовых единицах, поэтому
поштучные продажи корректно вычитаются из купленных пачек. Количество и цена
при этом вводятся в той фасовке, которую выбрал ты: «2 пачки по 200 ₽» так
и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь
пачку с 10 на 12 штук, и уже записанные документы не поедут.
**Себестоимость указывается за базовую единицу**, а не за фасовку. Пачка
печенья 10 шт за 200 ₽ — значит себестоимость 20 ₽ за штуку; в закупке
программа сама подставит 200 ₽, когда выберешь пачку. Чтобы это не приходилось
держать в голове, карточка товара показывает результат прямо под таблицей
фасовок: «В закупку подставится: пачка = 10 шт, себестоимость 200,00 ₽».
### Ввод истории
Кнопка «Быстрый ввод за период» на экране продаж: задаёшь диапазон дат,
каждая строка таблицы — отдельная продажа. Дата новой строки наследуется от
предыдущей, 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`, которые сами считают разницу и
дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать
изменение, и заметили бы это только когда история понадобится.