Долг было непонятно как записать, и не зря. Оплата была просто денежным полем с полной суммой: долг получался, если догадаться стереть число и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что оплачено полностью, а любая правка строк подставляла полную сумму обратно и молча стирала выставленный долг. Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил часть. Пересчёт работает только в первом режиме. Поле суммы осталось живым — вписанное руками число само зажигает подходящий переключатель. Долг без имени записать нельзя: на экране долгов такая запись попадает в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром вводе это предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это был. На экране долгов человека можно выбрать целиком и рассчитаться сразу за всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых. Цены фасовок переехали в «Цены и история». Они меняются одним решением поставщика вместе с ценой товара, а история была только у товара — у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке товара цены фасовок теперь только показываются, правится там состав. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .vscode | ||
| app | ||
| tests | ||
| .gitattributes | ||
| .gitignore | ||
| food-market.code-workspace | ||
| food-market.spec | ||
| pytest.ini | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
| run.py | ||
Булочная
Десктопный учёт закупки и перепродажи булок. Вся база — один файл, зашифрованный паролем, который раз в час уезжает в отдельный репозиторий.
Два репозитория, и это принципиально:
| репозиторий | что внутри |
|---|---|
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 нужен только чтобы понимать, деньги за какую партию уже пришли.
Прибыль считается как наценка с проданного плюс чаевые плюс прощённое пекарней минус себестоимость съеденного и подаренного: за съеденное пекарне платить всё равно, и покрывается это из маржи.
Как запустить
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/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, которые сами считают разницу и
дописывают запись в журнал. Иначе новый экран однажды забыл бы залогировать
изменение, и заметили бы это только когда история понадобится.