2026-08-22 21:29:53 +07:00
|
|
|
|
"""Структура документа базы и сериализация в/из JSON.
|
|
|
|
|
|
|
|
|
|
|
|
Вся база — один словарь, который целиком помещается в память. Датаклассы ниже
|
|
|
|
|
|
описывают его форму; на диск и обратно всё ходит через to_dict/from_dict,
|
|
|
|
|
|
чтобы Decimal и date не разъезжались с их JSON-представлением.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from datetime import date, datetime
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from . import money as m
|
|
|
|
|
|
|
Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.
Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.
Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.
На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.
Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:25:49 +07:00
|
|
|
|
SCHEMA_VERSION = 6
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
|
|
|
|
|
|
ONE = Decimal(1)
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
# --- Типы движения товара -------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# Тип определяет и подстановку цены в форме, и то, как продажа попадает
|
|
|
|
|
|
# в отчёты: приносит ли она деньги или это чистое выбытие себестоимости.
|
|
|
|
|
|
|
|
|
|
|
|
KIND_RETAIL = "retail"
|
|
|
|
|
|
KIND_FRIEND = "friend"
|
|
|
|
|
|
KIND_SELF = "self"
|
|
|
|
|
|
KIND_GIFT = "gift"
|
|
|
|
|
|
KIND_WRITEOFF = "writeoff"
|
|
|
|
|
|
|
|
|
|
|
|
SALE_KINDS = (KIND_RETAIL, KIND_FRIEND, KIND_SELF, KIND_GIFT, KIND_WRITEOFF)
|
|
|
|
|
|
|
|
|
|
|
|
SALE_KIND_LABELS = {
|
|
|
|
|
|
KIND_RETAIL: "Продажа",
|
|
|
|
|
|
KIND_FRIEND: "Другу по себестоимости",
|
|
|
|
|
|
KIND_SELF: "Съел сам",
|
|
|
|
|
|
KIND_GIFT: "Подарил",
|
|
|
|
|
|
KIND_WRITEOFF: "Списание",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Приносят выручку. Остальные типы — выбытие без денег: себестоимость всё равно
|
|
|
|
|
|
# придётся вернуть пекарне, поэтому она гасится из маржи.
|
|
|
|
|
|
REVENUE_KINDS = frozenset({KIND_RETAIL, KIND_FRIEND})
|
|
|
|
|
|
CONSUMPTION_KINDS = frozenset({KIND_SELF, KIND_GIFT, KIND_WRITEOFF})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def new_id(prefix: str) -> str:
|
|
|
|
|
|
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_date(value: Any) -> date:
|
|
|
|
|
|
if isinstance(value, datetime):
|
|
|
|
|
|
return value.date()
|
|
|
|
|
|
if isinstance(value, date):
|
|
|
|
|
|
return value
|
|
|
|
|
|
return date.fromisoformat(str(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dump_date(value: date) -> str:
|
|
|
|
|
|
return value.isoformat()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Номенклатура ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.
Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.
Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.
На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.
Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:25:49 +07:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class PackPrice:
|
|
|
|
|
|
"""Цены одной фасовки в момент снимка истории.
|
|
|
|
|
|
|
|
|
|
|
|
Хранится копией, а не ссылкой на фасовку: фасовку могут переименовать или
|
|
|
|
|
|
удалить, а история обязана остаться читаемой.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
|
cost_price: Decimal = m.ZERO
|
|
|
|
|
|
retail_price: Decimal = m.ZERO
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "PackPrice":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
name=d.get("name", ""),
|
|
|
|
|
|
cost_price=m.money(d.get("cost_price")),
|
|
|
|
|
|
retail_price=m.money(d.get("retail_price")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"cost_price": m.dumps(self.cost_price),
|
|
|
|
|
|
"retail_price": m.dumps(self.retail_price),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class PricePoint:
|
|
|
|
|
|
"""Снимок цен товара на момент изменения.
|
|
|
|
|
|
|
|
|
|
|
|
Пишется при каждой правке цены, поэтому история цен видна прямо в карточке
|
Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.
Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.
Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.
На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.
Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:25:49 +07:00
|
|
|
|
товара, не полагаясь на историю git. Цены фасовок входят в тот же снимок:
|
|
|
|
|
|
иначе у товара история была бы, а у коробки — нет, хотя дорожают они
|
|
|
|
|
|
обычно вместе.
|
2026-08-22 21:29:53 +07:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
date: date
|
|
|
|
|
|
cost_price: Decimal
|
|
|
|
|
|
retail_price: Decimal
|
|
|
|
|
|
note: str = ""
|
Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.
Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.
Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.
На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.
Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:25:49 +07:00
|
|
|
|
packs: list[PackPrice] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
def pack(self, name: str) -> "PackPrice | None":
|
|
|
|
|
|
return next((p for p in self.packs if p.name == name), None)
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "PricePoint":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
date=parse_date(d["date"]),
|
|
|
|
|
|
cost_price=m.money(d.get("cost_price")),
|
|
|
|
|
|
retail_price=m.money(d.get("retail_price")),
|
|
|
|
|
|
note=d.get("note", ""),
|
Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.
Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.
Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.
На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.
Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:25:49 +07:00
|
|
|
|
packs=[PackPrice.from_dict(p) for p in d.get("packs", [])],
|
2026-08-22 21:29:53 +07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"date": dump_date(self.date),
|
|
|
|
|
|
"cost_price": m.dumps(self.cost_price),
|
|
|
|
|
|
"retail_price": m.dumps(self.retail_price),
|
|
|
|
|
|
"note": self.note,
|
Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.
Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.
Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.
На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.
Цены фасовок переехали в «Цены и история». Они меняются одним решением
поставщика вместе с ценой товара, а история была только у товара —
у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке
товара цены фасовок теперь только показываются, правится там состав.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:25:49 +07:00
|
|
|
|
"packs": [p.to_dict() for p in self.packs],
|
2026-08-22 21:29:53 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class Pack:
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
"""Фасовка: сколько базовых единиц внутри, почём берётся и почём отдаётся.
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
Пачка печенья по 10 штук и та же печенька поштучно — это один товар
|
|
|
|
|
|
с двумя фасовками, а не два разных товара. Остатки и себестоимость всегда
|
|
|
|
|
|
считаются в базовых единицах, поэтому поштучные продажи корректно
|
|
|
|
|
|
вычитаются из купленных пачек.
|
|
|
|
|
|
|
|
|
|
|
|
**Обе цены — за всю фасовку целиком**, и обе свои, а не производные.
|
|
|
|
|
|
Коробку и берут, и отдают по другой цене, чем поштучный товар: ради этого
|
|
|
|
|
|
её и берут. Ноль означает «своей цены нет, считай от цены базовой единицы»
|
|
|
|
|
|
— так ведут себя все фасовки, заведённые до появления этого поля.
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
|
size: Decimal
|
|
|
|
|
|
retail_price: Decimal = m.ZERO
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
cost_price: Decimal = m.ZERO
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Pack":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
name=d.get("name", ""),
|
|
|
|
|
|
size=m.qty(d.get("size", 1)),
|
|
|
|
|
|
retail_price=m.money(d.get("retail_price")),
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
cost_price=m.money(d.get("cost_price")),
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"size": m.dumps(self.size),
|
|
|
|
|
|
"retail_price": m.dumps(self.retail_price),
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
"cost_price": m.dumps(self.cost_price),
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class Product:
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
"""Товар. Цены хранятся за базовую единицу, фасовки — надстройка над ней."""
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
id: str
|
|
|
|
|
|
name: str
|
|
|
|
|
|
unit: str = "шт"
|
|
|
|
|
|
cost_price: Decimal = m.ZERO
|
|
|
|
|
|
retail_price: Decimal = m.ZERO
|
|
|
|
|
|
archived: bool = False
|
|
|
|
|
|
note: str = ""
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
packs: list[Pack] = field(default_factory=list)
|
2026-08-22 21:29:53 +07:00
|
|
|
|
price_history: list[PricePoint] = field(default_factory=list)
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
# --- фасовки ---
|
|
|
|
|
|
|
|
|
|
|
|
def uom_names(self) -> list[str]:
|
|
|
|
|
|
"""Базовая единица плюс все фасовки, в порядке для выпадающего списка."""
|
|
|
|
|
|
return [self.unit] + [p.name for p in self.packs]
|
|
|
|
|
|
|
|
|
|
|
|
def pack(self, uom: str) -> Pack | None:
|
|
|
|
|
|
return next((p for p in self.packs if p.name == uom), None)
|
|
|
|
|
|
|
|
|
|
|
|
def size_of(self, uom: str) -> Decimal:
|
|
|
|
|
|
"""Сколько базовых единиц в одной штуке выбранной фасовки."""
|
|
|
|
|
|
pack = self.pack(uom)
|
|
|
|
|
|
return pack.size if pack else ONE
|
|
|
|
|
|
|
|
|
|
|
|
def cost_for(self, uom: str) -> Decimal:
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
"""Себестоимость одной штуки выбранной фасовки.
|
|
|
|
|
|
|
|
|
|
|
|
У фасовки закупочная цена своя: коробку почти всегда отдают дешевле,
|
|
|
|
|
|
чем те же штуки поодиночке, — ради этого её и берут. Выводить её
|
|
|
|
|
|
умножением поштучной цены на размер нельзя: получилось бы, что опт
|
|
|
|
|
|
стоит столько же, сколько розница.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return self._pack_price(uom, "cost_price", self.cost_price)
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
|
|
|
|
|
|
def retail_for(self, uom: str) -> Decimal:
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
"""Цена продажи одной штуки выбранной фасовки."""
|
|
|
|
|
|
return self._pack_price(uom, "retail_price", self.retail_price)
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
def _pack_price(self, uom: str, attr: str, base: Decimal) -> Decimal:
|
|
|
|
|
|
"""Цена за фасовку целиком: своя, а если не задана — от базовой."""
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
pack = self.pack(uom)
|
|
|
|
|
|
if pack is None:
|
Своя закупочная цена у фасовки
Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.
Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.
Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 19:46:55 +07:00
|
|
|
|
return base
|
|
|
|
|
|
own = getattr(pack, attr)
|
|
|
|
|
|
if own > 0:
|
|
|
|
|
|
return own
|
|
|
|
|
|
return m.money(base * pack.size)
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Product":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
id=d["id"],
|
|
|
|
|
|
name=d.get("name", ""),
|
|
|
|
|
|
unit=d.get("unit", "шт"),
|
|
|
|
|
|
cost_price=m.money(d.get("cost_price")),
|
|
|
|
|
|
retail_price=m.money(d.get("retail_price")),
|
|
|
|
|
|
archived=bool(d.get("archived", False)),
|
|
|
|
|
|
note=d.get("note", ""),
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
packs=[Pack.from_dict(p) for p in d.get("packs", [])],
|
2026-08-22 21:29:53 +07:00
|
|
|
|
price_history=[PricePoint.from_dict(p) for p in d.get("price_history", [])],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"unit": self.unit,
|
|
|
|
|
|
"cost_price": m.dumps(self.cost_price),
|
|
|
|
|
|
"retail_price": m.dumps(self.retail_price),
|
|
|
|
|
|
"archived": self.archived,
|
|
|
|
|
|
"note": self.note,
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
"packs": [p.to_dict() for p in self.packs],
|
2026-08-22 21:29:53 +07:00
|
|
|
|
"price_history": [p.to_dict() for p in self.price_history],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Counterparty:
|
|
|
|
|
|
id: str
|
|
|
|
|
|
name: str
|
|
|
|
|
|
kind: str = "friend"
|
|
|
|
|
|
note: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Counterparty":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
id=d["id"],
|
|
|
|
|
|
name=d.get("name", ""),
|
|
|
|
|
|
kind=d.get("kind", "friend"),
|
|
|
|
|
|
note=d.get("note", ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {"id": self.id, "name": self.name, "kind": self.kind, "note": self.note}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Закупки --------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
class DocumentLine:
|
|
|
|
|
|
"""Общее для строк закупки и продажи.
|
|
|
|
|
|
|
|
|
|
|
|
Количество и цена хранятся в той фасовке, которую выбрал пользователь —
|
|
|
|
|
|
«2 пачки по 200 ₽» так и остаётся двумя пачками по 200. Размер фасовки
|
|
|
|
|
|
сохраняется слепком: если пачку потом переопределят с 10 штук на 12,
|
|
|
|
|
|
уже записанные документы не должны поехать.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
product_id: str
|
|
|
|
|
|
qty: Decimal
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
uom: str = ""
|
|
|
|
|
|
uom_size: Decimal = ONE
|
|
|
|
|
|
|
|
|
|
|
|
def unit_name(self, product: "Product | None") -> str:
|
|
|
|
|
|
return self.uom or (product.unit if product else "")
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def base_qty(self) -> Decimal:
|
|
|
|
|
|
"""Количество в базовых единицах — в них считаются остатки и FIFO."""
|
|
|
|
|
|
return m.qty(self.qty * self.uom_size)
|
|
|
|
|
|
|
|
|
|
|
|
def _base_price(self, price: Decimal) -> Decimal:
|
|
|
|
|
|
"""Цена за базовую единицу. Без округления: делим 200 на 3 без потерь."""
|
|
|
|
|
|
if self.uom_size <= 0:
|
|
|
|
|
|
return m.ZERO
|
|
|
|
|
|
return price / self.uom_size
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _uom_from_dict(d: dict) -> tuple[str, Decimal]:
|
|
|
|
|
|
size = m.qty(d.get("uom_size", 1))
|
|
|
|
|
|
return d.get("uom", ""), size if size > 0 else ONE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class BatchLine(DocumentLine):
|
|
|
|
|
|
unit_cost: Decimal = m.ZERO
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def total(self) -> Decimal:
|
|
|
|
|
|
return m.money(self.qty * self.unit_cost)
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
@property
|
|
|
|
|
|
def base_unit_cost(self) -> Decimal:
|
|
|
|
|
|
return self._base_price(self.unit_cost)
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "BatchLine":
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
uom, size = cls._uom_from_dict(d)
|
2026-08-22 21:29:53 +07:00
|
|
|
|
return cls(
|
|
|
|
|
|
product_id=d["product_id"],
|
|
|
|
|
|
qty=m.qty(d.get("qty")),
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
uom=uom,
|
|
|
|
|
|
uom_size=size,
|
2026-08-22 21:29:53 +07:00
|
|
|
|
unit_cost=m.money(d.get("unit_cost")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"product_id": self.product_id,
|
|
|
|
|
|
"qty": m.dumps(self.qty),
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
"uom": self.uom,
|
|
|
|
|
|
"uom_size": m.dumps(self.uom_size),
|
2026-08-22 21:29:53 +07:00
|
|
|
|
"unit_cost": m.dumps(self.unit_cost),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Batch:
|
|
|
|
|
|
"""Партия булок, взятая в пекарне в долг.
|
|
|
|
|
|
|
|
|
|
|
|
unit_cost в строках — слепок цены на момент закупки: если цена у поставщика
|
|
|
|
|
|
потом изменится, долг по уже взятой партии не должен поехать.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
|
number: int
|
|
|
|
|
|
date: date
|
|
|
|
|
|
due_date: date
|
|
|
|
|
|
lines: list[BatchLine] = field(default_factory=list)
|
|
|
|
|
|
closed: bool = False
|
|
|
|
|
|
note: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def cost_total(self) -> Decimal:
|
|
|
|
|
|
"""Сколько надо вернуть пекарне за эту партию."""
|
|
|
|
|
|
return m.money(sum((line.total for line in self.lines), m.ZERO))
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def qty_total(self) -> Decimal:
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
"""Всего базовых единиц в партии: 2 пачки по 10 — это 20 штук."""
|
|
|
|
|
|
return m.qty(sum((line.base_qty for line in self.lines), m.ZERO))
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Batch":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
id=d["id"],
|
|
|
|
|
|
number=int(d.get("number", 0)),
|
|
|
|
|
|
date=parse_date(d["date"]),
|
|
|
|
|
|
due_date=parse_date(d["due_date"]),
|
|
|
|
|
|
lines=[BatchLine.from_dict(x) for x in d.get("lines", [])],
|
|
|
|
|
|
closed=bool(d.get("closed", False)),
|
|
|
|
|
|
note=d.get("note", ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"number": self.number,
|
|
|
|
|
|
"date": dump_date(self.date),
|
|
|
|
|
|
"due_date": dump_date(self.due_date),
|
|
|
|
|
|
"lines": [x.to_dict() for x in self.lines],
|
|
|
|
|
|
"closed": self.closed,
|
|
|
|
|
|
"note": self.note,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Продажи и выбытие ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
class SaleLine(DocumentLine):
|
|
|
|
|
|
unit_price: Decimal = m.ZERO
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def total(self) -> Decimal:
|
|
|
|
|
|
return m.money(self.qty * self.unit_price)
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
@property
|
|
|
|
|
|
def base_unit_price(self) -> Decimal:
|
|
|
|
|
|
return self._base_price(self.unit_price)
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "SaleLine":
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
uom, size = cls._uom_from_dict(d)
|
2026-08-22 21:29:53 +07:00
|
|
|
|
return cls(
|
|
|
|
|
|
product_id=d["product_id"],
|
|
|
|
|
|
qty=m.qty(d.get("qty")),
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
uom=uom,
|
|
|
|
|
|
uom_size=size,
|
2026-08-22 21:29:53 +07:00
|
|
|
|
unit_price=m.money(d.get("unit_price")),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"product_id": self.product_id,
|
|
|
|
|
|
"qty": m.dumps(self.qty),
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
"uom": self.uom,
|
|
|
|
|
|
"uom_size": m.dumps(self.uom_size),
|
2026-08-22 21:29:53 +07:00
|
|
|
|
"unit_price": m.dumps(self.unit_price),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Payment:
|
|
|
|
|
|
"""Приход денег по конкретной продаже (может быть частичным)."""
|
|
|
|
|
|
|
|
|
|
|
|
date: date
|
|
|
|
|
|
amount: Decimal
|
|
|
|
|
|
note: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Payment":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
date=parse_date(d["date"]),
|
|
|
|
|
|
amount=m.money(d.get("amount")),
|
|
|
|
|
|
note=d.get("note", ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"date": dump_date(self.date),
|
|
|
|
|
|
"amount": m.dumps(self.amount),
|
|
|
|
|
|
"note": self.note,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Sale:
|
|
|
|
|
|
"""Любое выбытие товара: продажа, отдача другу, съел сам, списание."""
|
|
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
|
date: date
|
|
|
|
|
|
kind: str = KIND_RETAIL
|
|
|
|
|
|
counterparty_id: str | None = None
|
|
|
|
|
|
lines: list[SaleLine] = field(default_factory=list)
|
|
|
|
|
|
payments: list[Payment] = field(default_factory=list)
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
# Деньги сверх стоимости товара. Живут отдельно от payments намеренно:
|
|
|
|
|
|
# иначе чаевые раздували бы «оплачено» и продажа выглядела бы закрытой,
|
|
|
|
|
|
# когда за булки ещё должны.
|
|
|
|
|
|
tip: Decimal = m.ZERO
|
2026-08-22 21:29:53 +07:00
|
|
|
|
note: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def total(self) -> Decimal:
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
"""Стоимость товара. Чаевые сюда не входят — за них ничего не должны."""
|
2026-08-22 21:29:53 +07:00
|
|
|
|
return m.money(sum((line.total for line in self.lines), m.ZERO))
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def paid(self) -> Decimal:
|
|
|
|
|
|
return m.money(sum((p.amount for p in self.payments), m.ZERO))
|
|
|
|
|
|
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
@property
|
|
|
|
|
|
def received(self) -> Decimal:
|
|
|
|
|
|
"""Сколько денег реально пришло по этой продаже, вместе с чаевыми."""
|
|
|
|
|
|
return m.money(self.paid + self.tip)
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@property
|
|
|
|
|
|
def debt(self) -> Decimal:
|
|
|
|
|
|
"""Сколько ещё должны по этой продаже (не бывает отрицательным)."""
|
|
|
|
|
|
return max(m.ZERO, m.money(self.total - self.paid))
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_consumption(self) -> bool:
|
|
|
|
|
|
return self.kind in CONSUMPTION_KINDS
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Sale":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
id=d["id"],
|
|
|
|
|
|
date=parse_date(d["date"]),
|
|
|
|
|
|
kind=d.get("kind", KIND_RETAIL),
|
|
|
|
|
|
counterparty_id=d.get("counterparty_id") or None,
|
|
|
|
|
|
lines=[SaleLine.from_dict(x) for x in d.get("lines", [])],
|
|
|
|
|
|
payments=[Payment.from_dict(x) for x in d.get("payments", [])],
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
tip=m.money(d.get("tip")),
|
2026-08-22 21:29:53 +07:00
|
|
|
|
note=d.get("note", ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"date": dump_date(self.date),
|
|
|
|
|
|
"kind": self.kind,
|
|
|
|
|
|
"counterparty_id": self.counterparty_id,
|
|
|
|
|
|
"lines": [x.to_dict() for x in self.lines],
|
|
|
|
|
|
"payments": [x.to_dict() for x in self.payments],
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
"tip": m.dumps(self.tip),
|
2026-08-22 21:29:53 +07:00
|
|
|
|
"note": self.note,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.
У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.
В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.
В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.
Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
|
|
|
|
BAKERY_CASH = "cash"
|
|
|
|
|
|
BAKERY_DISCOUNT = "discount"
|
|
|
|
|
|
|
|
|
|
|
|
BAKERY_KIND_LABELS = {
|
|
|
|
|
|
BAKERY_CASH: "Платёж",
|
|
|
|
|
|
BAKERY_DISCOUNT: "Скидка пекарни",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class BakeryPayment:
|
Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.
У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.
В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.
В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.
Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
|
|
|
|
"""Расчёт с пекарней.
|
|
|
|
|
|
|
|
|
|
|
|
Два вида, и различать их обязательно:
|
|
|
|
|
|
|
|
|
|
|
|
* `cash` — деньги реально ушли из кармана;
|
|
|
|
|
|
* `discount` — пекарня простила остаток. Долг закрыт, но денег никто не
|
|
|
|
|
|
отдавал, и для тебя это доход.
|
|
|
|
|
|
|
|
|
|
|
|
Свалить их в одну кучу нельзя: тогда «отдано пекарне» в статистике
|
|
|
|
|
|
показывало бы суммы, которых ты не платил.
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
batch_id=None означает «просто отдал денег» — такой платёж гасит долг
|
|
|
|
|
|
по старейшим непокрытым партиям (см. ledger).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
|
date: date
|
|
|
|
|
|
amount: Decimal
|
|
|
|
|
|
batch_id: str | None = None
|
Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.
У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.
В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.
В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.
Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
|
|
|
|
kind: str = BAKERY_CASH
|
2026-08-22 21:29:53 +07:00
|
|
|
|
note: str = ""
|
|
|
|
|
|
|
Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.
У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.
В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.
В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.
Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
|
|
|
|
@property
|
|
|
|
|
|
def is_discount(self) -> bool:
|
|
|
|
|
|
return self.kind == BAKERY_DISCOUNT
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "BakeryPayment":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
id=d["id"],
|
|
|
|
|
|
date=parse_date(d["date"]),
|
|
|
|
|
|
amount=m.money(d.get("amount")),
|
|
|
|
|
|
batch_id=d.get("batch_id") or None,
|
Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.
У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.
В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.
В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.
Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
|
|
|
|
kind=d.get("kind", BAKERY_CASH),
|
2026-08-22 21:29:53 +07:00
|
|
|
|
note=d.get("note", ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"date": dump_date(self.date),
|
|
|
|
|
|
"amount": m.dumps(self.amount),
|
|
|
|
|
|
"batch_id": self.batch_id,
|
Пекарня может простить остаток
Надо было отдать 5 500, взяли 5 000, полтысячи оставили. Долг закрыт,
но денег никто не отдавал.
У расчёта с пекарней появился вид: деньги или прощённый остаток. Свалить
их в одну кучу нельзя — тогда «Отдано пекарне» в статистике показывало бы
суммы, которых не платил. Поэтому «Отдано» — только живые деньги,
«Прощено» — отдельная колонка везде: в закупках, в сводке, в статистике
по периодам и по времени.
В прибыль прощённое идёт целиком: эти деньги предназначались пекарне,
а остались у тебя. «Осталось собрать» на ту же сумму уменьшается —
собирать под прощённый остаток уже не надо. Простить больше долга нельзя:
лишнее не засчитывается и, в отличие от переплаты деньгами, никуда
не переливается.
В диалоге платежа галочка «Остаток простили — закрыть партию»: вводишь
сколько отдал, остаток уходит отдельной записью, партия закрывается сама,
потому что её долг становится нулём.
Миграция схемы 3→4 помечает всё записанное раньше как живые деньги.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:44:19 +07:00
|
|
|
|
"kind": self.kind,
|
2026-08-22 21:29:53 +07:00
|
|
|
|
"note": self.note,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Журнал ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Change:
|
|
|
|
|
|
field: str
|
|
|
|
|
|
old: str
|
|
|
|
|
|
new: str
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Change":
|
|
|
|
|
|
return cls(field=d.get("field", ""), old=d.get("old", ""), new=d.get("new", ""))
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {"field": self.field, "old": self.old, "new": self.new}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class JournalEntry:
|
|
|
|
|
|
"""Запись аудит-лога. Пишется только через journal.apply, только append."""
|
|
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
|
ts: str
|
|
|
|
|
|
host: str
|
|
|
|
|
|
action: str
|
|
|
|
|
|
entity: str
|
|
|
|
|
|
entity_id: str
|
|
|
|
|
|
label: str
|
|
|
|
|
|
changes: list[Change] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "JournalEntry":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
id=d["id"],
|
|
|
|
|
|
ts=d.get("ts", ""),
|
|
|
|
|
|
host=d.get("host", ""),
|
|
|
|
|
|
action=d.get("action", ""),
|
|
|
|
|
|
entity=d.get("entity", ""),
|
|
|
|
|
|
entity_id=d.get("entity_id", ""),
|
|
|
|
|
|
label=d.get("label", ""),
|
|
|
|
|
|
changes=[Change.from_dict(c) for c in d.get("changes", [])],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"ts": self.ts,
|
|
|
|
|
|
"host": self.host,
|
|
|
|
|
|
"action": self.action,
|
|
|
|
|
|
"entity": self.entity,
|
|
|
|
|
|
"entity_id": self.entity_id,
|
|
|
|
|
|
"label": self.label,
|
|
|
|
|
|
"changes": [c.to_dict() for c in self.changes],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Настройки ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class GitSettings:
|
|
|
|
|
|
"""Токен лежит здесь, то есть внутри зашифрованной базы.
|
|
|
|
|
|
|
|
|
|
|
|
В .git/config он не попадает — иначе лежал бы открытым рядом с шифром.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
# По умолчанию — репозиторий данных, а не исходников. Код и база
|
|
|
|
|
|
# намеренно живут врозь: пуш отправляет ветку целиком.
|
|
|
|
|
|
remote_url: str = "https://gt.ser.gay/kizya/food-records.git"
|
2026-08-22 21:29:53 +07:00
|
|
|
|
token: str = ""
|
|
|
|
|
|
branch: str = "main"
|
|
|
|
|
|
author_name: str = "food-market"
|
|
|
|
|
|
author_email: str = "food-market@localhost"
|
|
|
|
|
|
auto_push_minutes: int = 60
|
|
|
|
|
|
enabled: bool = True
|
|
|
|
|
|
daily_squash: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "GitSettings":
|
|
|
|
|
|
base = cls()
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
remote_url=d.get("remote_url", base.remote_url),
|
|
|
|
|
|
token=d.get("token", base.token),
|
|
|
|
|
|
branch=d.get("branch", base.branch) or base.branch,
|
|
|
|
|
|
author_name=d.get("author_name") or base.author_name,
|
|
|
|
|
|
author_email=d.get("author_email") or base.author_email,
|
|
|
|
|
|
auto_push_minutes=int(d.get("auto_push_minutes", base.auto_push_minutes)),
|
|
|
|
|
|
enabled=bool(d.get("enabled", base.enabled)),
|
|
|
|
|
|
daily_squash=bool(d.get("daily_squash", base.daily_squash)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"remote_url": self.remote_url,
|
|
|
|
|
|
"token": self.token,
|
|
|
|
|
|
"branch": self.branch,
|
|
|
|
|
|
"author_name": self.author_name,
|
|
|
|
|
|
"author_email": self.author_email,
|
|
|
|
|
|
"auto_push_minutes": self.auto_push_minutes,
|
|
|
|
|
|
"enabled": self.enabled,
|
|
|
|
|
|
"daily_squash": self.daily_squash,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Settings:
|
|
|
|
|
|
currency: str = "₽"
|
|
|
|
|
|
default_period_days: int = 14
|
|
|
|
|
|
git: GitSettings = field(default_factory=GitSettings)
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Settings":
|
|
|
|
|
|
base = cls()
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
currency=d.get("currency", base.currency),
|
|
|
|
|
|
default_period_days=int(
|
|
|
|
|
|
d.get("default_period_days", base.default_period_days)
|
|
|
|
|
|
),
|
|
|
|
|
|
git=GitSettings.from_dict(d.get("git", {})),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"currency": self.currency,
|
|
|
|
|
|
"default_period_days": self.default_period_days,
|
|
|
|
|
|
"git": self.git.to_dict(),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Документ целиком -----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Document:
|
|
|
|
|
|
schema_version: int = SCHEMA_VERSION
|
|
|
|
|
|
settings: Settings = field(default_factory=Settings)
|
|
|
|
|
|
products: list[Product] = field(default_factory=list)
|
|
|
|
|
|
counterparties: list[Counterparty] = field(default_factory=list)
|
|
|
|
|
|
batches: list[Batch] = field(default_factory=list)
|
|
|
|
|
|
sales: list[Sale] = field(default_factory=list)
|
|
|
|
|
|
bakery_payments: list[BakeryPayment] = field(default_factory=list)
|
|
|
|
|
|
journal: list[JournalEntry] = field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_dict(cls, d: dict) -> "Document":
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
schema_version=int(d.get("schema_version", SCHEMA_VERSION)),
|
|
|
|
|
|
settings=Settings.from_dict(d.get("settings", {})),
|
|
|
|
|
|
products=[Product.from_dict(x) for x in d.get("products", [])],
|
|
|
|
|
|
counterparties=[
|
|
|
|
|
|
Counterparty.from_dict(x) for x in d.get("counterparties", [])
|
|
|
|
|
|
],
|
|
|
|
|
|
batches=[Batch.from_dict(x) for x in d.get("batches", [])],
|
|
|
|
|
|
sales=[Sale.from_dict(x) for x in d.get("sales", [])],
|
|
|
|
|
|
bakery_payments=[
|
|
|
|
|
|
BakeryPayment.from_dict(x) for x in d.get("bakery_payments", [])
|
|
|
|
|
|
],
|
|
|
|
|
|
journal=[JournalEntry.from_dict(x) for x in d.get("journal", [])],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"schema_version": self.schema_version,
|
|
|
|
|
|
"settings": self.settings.to_dict(),
|
|
|
|
|
|
"products": [x.to_dict() for x in self.products],
|
|
|
|
|
|
"counterparties": [x.to_dict() for x in self.counterparties],
|
|
|
|
|
|
"batches": [x.to_dict() for x in self.batches],
|
|
|
|
|
|
"sales": [x.to_dict() for x in self.sales],
|
|
|
|
|
|
"bakery_payments": [x.to_dict() for x in self.bakery_payments],
|
|
|
|
|
|
"journal": [x.to_dict() for x in self.journal],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# --- поиск по id ---
|
|
|
|
|
|
|
|
|
|
|
|
def product(self, product_id: str) -> Product | None:
|
|
|
|
|
|
return _find(self.products, product_id)
|
|
|
|
|
|
|
|
|
|
|
|
def counterparty(self, cp_id: str) -> Counterparty | None:
|
|
|
|
|
|
return _find(self.counterparties, cp_id)
|
|
|
|
|
|
|
|
|
|
|
|
def batch(self, batch_id: str) -> Batch | None:
|
|
|
|
|
|
return _find(self.batches, batch_id)
|
|
|
|
|
|
|
|
|
|
|
|
def sale(self, sale_id: str) -> Sale | None:
|
|
|
|
|
|
return _find(self.sales, sale_id)
|
|
|
|
|
|
|
|
|
|
|
|
def product_name(self, product_id: str) -> str:
|
|
|
|
|
|
p = self.product(product_id)
|
|
|
|
|
|
return p.name if p else "(удалённый товар)"
|
|
|
|
|
|
|
|
|
|
|
|
def counterparty_name(self, cp_id: str | None) -> str:
|
|
|
|
|
|
if not cp_id:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
c = self.counterparty(cp_id)
|
|
|
|
|
|
return c.name if c else "(удалённый контрагент)"
|
|
|
|
|
|
|
|
|
|
|
|
def next_batch_number(self) -> int:
|
|
|
|
|
|
return max((b.number for b in self.batches), default=0) + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find(items: list, item_id: str):
|
|
|
|
|
|
for item in items:
|
|
|
|
|
|
if item.id == item_id:
|
|
|
|
|
|
return item
|
|
|
|
|
|
return None
|