Go to file
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
.vscode Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00
app Своя закупочная цена у фасовки 2026-08-24 15:46:55 +03:00
tests Своя закупочная цена у фасовки 2026-08-24 15:46:55 +03:00
.gitattributes Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00
.gitignore Отдельный репозиторий для базы, фасовки товара, ввод истории 2026-08-22 21:25:39 +03:00
food-market.code-workspace Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00
food-market.spec Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00
pytest.ini Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00
README.md Своя закупочная цена у фасовки 2026-08-24 15:46:55 +03:00
requirements-dev.txt Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00
requirements.txt Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00
run.py Учёт закупки и перепродажи булок 2026-08-22 17:29:53 +03:00

Булочная

Десктопный учёт закупки и перепродажи булок. Вся база — один файл, зашифрованный паролем, который раз в час уезжает в отдельный репозиторий.

Два репозитория, и это принципиально:

репозиторий что внутри
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 нужен только чтобы понимать, деньги за какую партию уже пришли.

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

Как запустить

pip install -r requirements.txt
python run.py

Собрать один exe:

pip install -r requirements-dev.txt
pyinstaller food-market.spec

Готовый dist/food-market.exe (~50 МБ) можно положить куда угодно — базу он ищет в папке data/ рядом с собой и при первом запуске сам заведёт там клон репозитория с данными.

На новой машине быстрее склонировать данные сразу:

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, а выбор предлагается в настройках. Ничего не затирается молча.

Разработка

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, которые сами считают разницу и дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать изменение, и заметили бы это только когда история понадобится.