food-market/app/ledger.py

504 lines
18 KiB
Python
Raw Normal View History

"""Расчёты по партиям, долгам и остаткам. Чистые функции, без Qt.
Главный вопрос, на который отвечает модуль: успеваю ли я собрать себестоимость
конкретной партии до её дедлайна. Чтобы на него ответить, недостаточно знать
общую выручку нужно понимать, деньги за какую именно партию уже пришли.
Поэтому продажи распределяются по партиям методом FIFO.
Цены у поставщика фиксированные, так что на саму себестоимость выбор FIFO
не влияет. Он нужен ровно для привязки выручки к периоду.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from . import money as m
from .models import (
Batch,
CONSUMPTION_KINDS,
Document,
Sale,
)
# За сколько дней до дедлайна партия начинает считаться «горящей».
DUE_SOON_DAYS = 3
STATUS_SETTLED = "settled"
STATUS_OVERDUE = "overdue"
STATUS_DUE_SOON = "due_soon"
STATUS_OPEN = "open"
STATUS_LABELS = {
STATUS_SETTLED: "Закрыта",
STATUS_OVERDUE: "Просрочена",
STATUS_DUE_SOON: "Горит",
STATUS_OPEN: "В работе",
}
@dataclass
class Allocation:
"""Кусок продажи, списанный с конкретной партии."""
sale_id: str
line_index: int
product_id: str
batch_id: str
qty: Decimal
unit_cost: Decimal
unit_price: Decimal
kind: str
paid_fraction: Decimal
@property
def cost(self) -> Decimal:
return self.qty * self.unit_cost
@property
def revenue(self) -> Decimal:
"""Начислено — включая то, что ещё не оплачено."""
return self.qty * self.unit_price
@property
def cash(self) -> Decimal:
"""Реально полученные деньги с учётом частичной оплаты."""
return self.revenue * self.paid_fraction
@dataclass
class Shortfall:
"""Продали больше, чем закупали: столько-то штук взялись из ниоткуда."""
sale_id: str
product_id: str
qty: Decimal
@dataclass
class BatchReport:
batch: Batch
cost_total: Decimal
qty_total: Decimal
qty_sold: Decimal
qty_left: Decimal
revenue: Decimal
cash_collected: Decimal
consumed_cost: Decimal
paid_to_bakery: Decimal
days_left: int
status: str
stock: dict[str, Decimal] = field(default_factory=dict)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
stock_cost: Decimal = m.ZERO
@property
def remaining_to_bakery(self) -> Decimal:
return max(m.ZERO, m.money(self.cost_total - self.paid_to_bakery))
@property
def coverage_pct(self) -> Decimal:
"""Какая доля себестоимости уже собрана деньгами."""
if self.cost_total <= 0:
return Decimal(100)
return m.money(self.cash_collected / self.cost_total * 100)
@property
def still_to_collect(self) -> Decimal:
"""Сколько денег ещё надо собрать, чтобы выйти в ноль по этой партии."""
return max(m.ZERO, m.money(self.cost_total - self.cash_collected))
@property
def label(self) -> str:
return f"{self.batch.number} от {self.batch.date.strftime('%d.%m.%Y')}"
@dataclass
class DebtorReport:
counterparty_id: str | None
name: str
debt: Decimal
sales: list[Sale] = field(default_factory=list)
@dataclass
class StockRow:
product_id: str
name: str
unit: str
qty: Decimal
cost: Decimal
@dataclass
class Summary:
owed_to_bakery: Decimal
receivable: Decimal
revenue: Decimal
cash_collected: Decimal
gross_margin: Decimal
consumed_cost: Decimal
stock_qty: Decimal
stock_cost: Decimal
overdue_count: int
next_due: BatchReport | None
@property
def profit(self) -> Decimal:
"""Что осталось тебе.
Съеденное и подаренное вычитается: пекарне за эти булки всё равно
платить, и покрывается это из наценки на проданных.
"""
return m.money(self.gross_margin - self.consumed_cost)
@dataclass
class Report:
today: date
batches: list[BatchReport]
allocations: list[Allocation]
shortfalls: list[Shortfall]
debtors: list[DebtorReport]
stock: list[StockRow]
summary: Summary
unassigned_bakery_payment: Decimal
warnings: list[str]
def batch_report(self, batch_id: str) -> BatchReport | None:
return next((r for r in self.batches if r.batch.id == batch_id), None)
@property
def open_batches(self) -> list[BatchReport]:
return [r for r in self.batches if r.status != STATUS_SETTLED]
# --- Распределение продаж по партиям --------------------------------------
def _paid_fraction(sale: Sale) -> Decimal:
"""Какая доля продажи оплачена. Переплата не даёт больше единицы."""
total = sale.total
if total <= 0:
return m.ZERO
return min(Decimal(1), sale.paid / total)
def _ordered_batches(doc: Document) -> list[tuple[int, Batch]]:
return sorted(enumerate(doc.batches), key=lambda t: (t[1].date, t[1].number, t[0]))
def _ordered_sales(doc: Document) -> list[tuple[int, Sale]]:
# Порядок ввода — вторичный ключ: две продажи одной датой должны
# распределяться одинаково при каждом пересчёте.
return sorted(enumerate(doc.sales), key=lambda t: (t[1].date, t[0]))
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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 batch_unit_costs(doc: Document) -> dict[tuple[str, str], Decimal]:
"""Себестоимость базовой единицы по каждой паре (партия, товар).
Себестоимость слепок цены на момент закупки. Если один товар попал
в партию несколькими строками (например, пачками и поштучно), берём
средневзвешенную: остаток по товару всё равно единый.
"""
totals: dict[tuple[str, str], list[Decimal]] = {}
for batch in doc.batches:
for line in batch.lines:
key = (batch.id, line.product_id)
acc = totals.setdefault(key, [m.ZERO, m.ZERO])
acc[0] += line.base_qty * line.base_unit_cost
acc[1] += line.base_qty
return {
key: (cost / qty if qty > 0 else m.ZERO) for key, (cost, qty) in totals.items()
}
def allocate(
doc: Document,
) -> tuple[list[Allocation], list[Shortfall], dict[str, dict[str, Decimal]]]:
"""Разложить продажи по партиям методом FIFO.
Возвращает аллокации, нехватку товара и остаток по каждой партии.
Намеренно не проверяем, что дата продажи позже даты партии: люди заводят
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
документы в произвольном порядке особенно когда вносят историю задним
числом, и жаловаться на это было бы шумом. Значение имеет только очередь
партий.
"""
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
# Всё внутри — в базовых единицах. Иначе поштучная продажа не вычиталась бы
# из купленной пачки: это один товар, а не два разных.
remaining: dict[str, dict[str, Decimal]] = {}
for _, batch in _ordered_batches(doc):
per_product = remaining.setdefault(batch.id, {})
for line in batch.lines:
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
per_product[line.product_id] = per_product.get(line.product_id, m.ZERO) + line.base_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
unit_costs = batch_unit_costs(doc)
order = [batch.id for _, batch in _ordered_batches(doc)]
allocations: list[Allocation] = []
shortfalls: list[Shortfall] = []
for _, sale in _ordered_sales(doc):
fraction = _paid_fraction(sale)
for line_index, line in enumerate(sale.lines):
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
need = line.base_qty
for batch_id in order:
if need <= 0:
break
available = remaining.get(batch_id, {}).get(line.product_id, m.ZERO)
if available <= 0:
continue
take = min(available, need)
remaining[batch_id][line.product_id] = available - take
need -= take
allocations.append(
Allocation(
sale_id=sale.id,
line_index=line_index,
product_id=line.product_id,
batch_id=batch_id,
qty=take,
unit_cost=unit_costs.get((batch_id, line.product_id), 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
unit_price=line.base_unit_price,
kind=sale.kind,
paid_fraction=fraction,
)
)
if need > 0:
shortfalls.append(
Shortfall(sale_id=sale.id, product_id=line.product_id, qty=need)
)
return allocations, shortfalls, remaining
# --- Разнесение платежей пекарне ------------------------------------------
def _spread_bakery_payments(doc: Document, cost_totals: dict[str, Decimal]) -> tuple[dict[str, Decimal], Decimal]:
"""Разложить платежи пекарне по партиям.
Платёж с указанной партией гасит её; излишек не превращается в
отрицательный долг, а переливается в общий котёл. Платежи без привязки
гасят старейшие непокрытые партии так же, как это происходит в жизни.
"""
paid: dict[str, Decimal] = {batch_id: m.ZERO for batch_id in cost_totals}
pool = m.ZERO
for payment in doc.bakery_payments:
batch_id = payment.batch_id
if batch_id and batch_id in paid:
room = max(m.ZERO, cost_totals[batch_id] - paid[batch_id])
applied = min(room, payment.amount)
paid[batch_id] += applied
pool += payment.amount - applied
else:
pool += payment.amount
for _, batch in _ordered_batches(doc):
if pool <= 0:
break
room = max(m.ZERO, cost_totals.get(batch.id, m.ZERO) - paid.get(batch.id, m.ZERO))
applied = min(room, pool)
paid[batch.id] = paid.get(batch.id, m.ZERO) + applied
pool -= applied
return paid, pool
# --- Сборка отчёта --------------------------------------------------------
def _status(batch: Batch, remaining_to_bakery: Decimal, days_left: int) -> str:
if batch.closed or remaining_to_bakery <= 0:
return STATUS_SETTLED
if days_left < 0:
return STATUS_OVERDUE
if days_left <= DUE_SOON_DAYS:
return STATUS_DUE_SOON
return STATUS_OPEN
def build(doc: Document, today: date | None = None) -> Report:
"""Пересчитать всё. Дешевле, чем поддерживать инкрементальное состояние."""
today = today or date.today()
allocations, shortfalls, remaining = allocate(doc)
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
unit_costs = batch_unit_costs(doc)
cost_totals = {batch.id: batch.cost_total for batch in doc.batches}
paid_to_bakery, unassigned = _spread_bakery_payments(doc, cost_totals)
per_batch: dict[str, dict[str, Decimal]] = {
batch.id: {"qty_sold": m.ZERO, "revenue": m.ZERO, "cash": m.ZERO, "consumed": m.ZERO}
for batch in doc.batches
}
for a in allocations:
bucket = per_batch.get(a.batch_id)
if bucket is None:
continue
bucket["qty_sold"] += a.qty
if a.kind in CONSUMPTION_KINDS:
bucket["consumed"] += a.cost
else:
bucket["revenue"] += a.revenue
bucket["cash"] += a.cash
batch_reports: list[BatchReport] = []
for _, batch in _ordered_batches(doc):
bucket = per_batch[batch.id]
cost_total = cost_totals[batch.id]
paid = m.money(paid_to_bakery.get(batch.id, m.ZERO))
days_left = (batch.due_date - today).days
stock = {
pid: qty
for pid, qty in remaining.get(batch.id, {}).items()
if qty > 0
}
batch_reports.append(
BatchReport(
batch=batch,
cost_total=cost_total,
qty_total=batch.qty_total,
qty_sold=m.qty(bucket["qty_sold"]),
qty_left=m.qty(sum(stock.values(), m.ZERO)),
revenue=m.money(bucket["revenue"]),
cash_collected=m.money(bucket["cash"]),
consumed_cost=m.money(bucket["consumed"]),
paid_to_bakery=paid,
days_left=days_left,
status=_status(batch, max(m.ZERO, cost_total - paid), days_left),
stock=stock,
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
stock_cost=m.money(
sum(
(
qty * unit_costs.get((batch.id, pid), m.ZERO)
for pid, qty in stock.items()
),
m.ZERO,
)
),
)
)
return Report(
today=today,
batches=batch_reports,
allocations=allocations,
shortfalls=shortfalls,
debtors=_debtors(doc),
stock=_stock(doc, batch_reports),
summary=_summary(doc, batch_reports, allocations, today),
unassigned_bakery_payment=m.money(unassigned),
warnings=_warnings(doc, shortfalls, unassigned),
)
def _debtors(doc: Document) -> list[DebtorReport]:
"""Кто и сколько должен. Продажи без контрагента идут отдельной строкой."""
buckets: dict[str | None, DebtorReport] = {}
for sale in doc.sales:
if sale.debt <= 0:
continue
key = sale.counterparty_id
report = buckets.get(key)
if report is None:
report = DebtorReport(
counterparty_id=key,
name=doc.counterparty_name(key) if key else "Без контрагента",
debt=m.ZERO,
)
buckets[key] = report
report.debt = m.money(report.debt + sale.debt)
report.sales.append(sale)
return sorted(buckets.values(), key=lambda r: (-r.debt, r.name))
def _stock(doc: Document, batch_reports: list[BatchReport]) -> list[StockRow]:
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
unit_costs = batch_unit_costs(doc)
qty_by_product: dict[str, Decimal] = {}
cost_by_product: dict[str, Decimal] = {}
for report in batch_reports:
for product_id, qty in report.stock.items():
Отдельный репозиторий для базы, фасовки товара, ввод истории Три правки по замечаниям. 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
unit_cost = unit_costs.get((report.batch.id, product_id), m.ZERO)
qty_by_product[product_id] = qty_by_product.get(product_id, m.ZERO) + qty
cost_by_product[product_id] = cost_by_product.get(product_id, m.ZERO) + qty * unit_cost
rows = []
for product in doc.products:
qty = qty_by_product.get(product.id, m.ZERO)
if qty <= 0 and product.archived:
continue
rows.append(
StockRow(
product_id=product.id,
name=product.name,
unit=product.unit,
qty=m.qty(qty),
cost=m.money(cost_by_product.get(product.id, m.ZERO)),
)
)
return sorted(rows, key=lambda r: r.name)
def _summary(
doc: Document,
batch_reports: list[BatchReport],
allocations: list[Allocation],
today: date,
) -> Summary:
revenue = m.ZERO
cash = m.ZERO
margin = m.ZERO
consumed = m.ZERO
for a in allocations:
if a.kind in CONSUMPTION_KINDS:
consumed += a.cost
else:
revenue += a.revenue
cash += a.cash
margin += a.revenue - a.cost
open_reports = [r for r in batch_reports if r.status != STATUS_SETTLED]
next_due = min(open_reports, key=lambda r: r.batch.due_date, default=None)
return Summary(
owed_to_bakery=m.money(sum((r.remaining_to_bakery for r in batch_reports), m.ZERO)),
receivable=m.money(sum((s.debt for s in doc.sales), m.ZERO)),
revenue=m.money(revenue),
cash_collected=m.money(cash),
gross_margin=m.money(margin),
consumed_cost=m.money(consumed),
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), 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
stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)),
overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE),
next_due=next_due,
)
def _warnings(doc: Document, shortfalls: list[Shortfall], unassigned: Decimal) -> list[str]:
"""То, что стоит показать пользователю, но что не должно ломать расчёт."""
warnings: list[str] = []
by_product: dict[str, Decimal] = {}
for s in shortfalls:
by_product[s.product_id] = by_product.get(s.product_id, m.ZERO) + s.qty
for product_id, qty in by_product.items():
warnings.append(
f"«{doc.product_name(product_id)}»: продано на {m.fmt_qty(qty)} больше, "
"чем закуплено — проверь закупки."
)
if unassigned > 0:
warnings.append(
f"Пекарне переплачено {m.fmt_money(unassigned, doc.settings.currency)}"
"это аванс под будущие партии."
)
return warnings