Раздел статистики

Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.

Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.

Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.

График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.

Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-08-23 02:33:11 +03:00
parent 582f041dba
commit 38e9614a25
7 changed files with 1636 additions and 21 deletions

View File

@ -29,9 +29,49 @@
Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период. Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период.
- **Долги** — кто сколько должен, с частичными оплатами. - **Долги** — кто сколько должен, с частичными оплатами.
- **Товары** — номенклатура с фасовками и историей изменения цен. - **Товары** — номенклатура с фасовками и историей изменения цен.
- **Статистика** — периоды, недели, месяцы, годы, разрезы по товарам и людям.
- **Журнал** — что менялось в базе, когда и с какой машины. - **Журнал** — что менялось в базе, когда и с какой машины.
- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн.
### Статистика
Шесть разрезов, у каждого график и таблица с итоговой строкой.
| Разрез | Что показывает |
|---|---|
| **Периоды** | Каждая закупка — период со своим сроком. Долг, отдано, собрано, покрытие, прибыль, статус |
| **Недели** | По ISO-неделям, понедельник–воскресенье |
| **Месяцы** | Календарные месяцы |
| **Годы** | Календарные годы |
| **Товары** | Кто кормит, а кто лежит: наценка, наценка %, съедено, остаток |
| **Люди** | Кто сколько взял, занёс, оставил чаевых и остался должен |
Пустые корзины между непустыми не создаются: если в марте не торговали,
строки за март не будет. Дырка честнее выдуманного нуля.
**База расчёта.** Смешивать «когда продал» и «когда получил деньги» нельзя —
это разные вопросы, и ответы расходятся, как только появляется хоть один долг.
Поэтому в таблице два основания одновременно:
- по дате **продажи** — выручка, себестоимость, наценка, съеденное, чаевые.
Это «сколько наторговал за месяц»;
- по дате **платежа** — колонка «Собрано». Это «сколько денег реально пришло»,
и долг, возвращённый в сентябре, попадёт в сентябрь, а не в августовскую
продажу;
- по дате **закупки** — «Закуплено», по дате платежа пекарне — «Отдано пекарне».
У разреза «Периоды» основание третье: там всё привязано к самой партии, потому
что вопрос стоит иначе — «покрыл ли я эту партию к сроку».
Общее количество нигде не показывается одним числом — только по единицам
(«1,5 л · 4 шт»). Сумма литров с килограммами выглядит как настоящая цифра,
но смысла не имеет.
График рисуется на QPainter, а не на QtCharts: та тянет за собой десятки
мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Если
корзин больше 24, на графике показываются последние, и об этом написано прямо
над ним — молча показанная часть выглядела бы как всё.
### Чаевые ### Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
@ -180,9 +220,11 @@ pytest -q
| `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» | | `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» |
| `app/journal.py` | **единственный путь записи** в базу + аудит-лог | | `app/journal.py` | **единственный путь записи** в базу + аудит-лог |
| `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки | | `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки |
| `app/stats.py` | сводные цифры по периодам, товарам и людям |
| `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений | | `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений |
| `app/paths.py` | где лежат данные и почему отдельно от кода | | `app/paths.py` | где лежат данные и почему отдельно от кода |
| `app/ui/quick_sales.py` | быстрый ввод продаж за период | | `app/ui/quick_sales.py` | быстрый ввод продаж за период |
| `app/ui/chart.py` | столбчатый график на QPainter |
| `app/ui/` | остальные экраны на PySide6 | | `app/ui/` | остальные экраны на PySide6 |
Главное архитектурное правило: **ни один экран не меняет документ напрямую**. Главное архитектурное правило: **ни один экран не меняет документ напрямую**.

439
app/stats.py Normal file
View File

@ -0,0 +1,439 @@
"""Статистика: сводные цифры по периодам, товарам и людям.
Чистые функции без Qt, работают поверх готового отчёта из ledger.
**Важное про базу расчёта.** Смешивать «когда продал» и «когда получил
деньги» нельзя это разные вопросы, и ответы на них расходятся, как только
появляется хоть один долг. Поэтому здесь два основания одновременно, и каждая
цифра честно приписана своему:
* по дате **продажи** выручка, себестоимость проданного, наценка, съеденное
и чаевые. Это «сколько я наторговал за месяц»;
* по дате **платежа** собранные деньги. Это «сколько денег реально пришло
в кассу за месяц», и в другой месяц они попадут, если долг вернули позже;
* по дате **закупки** взятое у пекарни;
* по дате **платежа пекарне** отданное ей.
У разреза по партиям основание третье: там всё привязано к самой партии,
потому что вопрос стоит иначе «покрыл ли я эту партию к сроку».
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal
from . import ledger
from . import money as m
from .models import CONSUMPTION_KINDS, Document
GRAIN_PERIOD = "period"
GRAIN_WEEK = "week"
GRAIN_MONTH = "month"
GRAIN_YEAR = "year"
GRAIN_LABELS = {
GRAIN_PERIOD: "Периоды",
GRAIN_WEEK: "Недели",
GRAIN_MONTH: "Месяцы",
GRAIN_YEAR: "Годы",
}
MONTHS_NOMINATIVE = (
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
)
MONTHS_GENITIVE = (
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
)
# --- границы и подписи периодов -------------------------------------------
def week_bounds(day: date) -> tuple[date, date]:
"""Понедельник и воскресенье недели по ISO."""
start = day - timedelta(days=day.isoweekday() - 1)
return start, start + timedelta(days=6)
def month_bounds(day: date) -> tuple[date, date]:
start = day.replace(day=1)
end = (start.replace(year=start.year + 1, month=1) if start.month == 12
else start.replace(month=start.month + 1)) - timedelta(days=1)
return start, end
def year_bounds(day: date) -> tuple[date, date]:
return day.replace(month=1, day=1), day.replace(month=12, day=31)
def bucket_of(day: date, grain: str) -> tuple[str, str, date, date]:
"""Ключ, подпись и границы корзины, в которую попадает дата."""
if grain == GRAIN_WEEK:
start, end = week_bounds(day)
iso_year, iso_week, _ = day.isocalendar()
label = f"Неделя {iso_week} · {_span(start, end)}"
return f"{iso_year}-W{iso_week:02d}", label, start, end
if grain == GRAIN_MONTH:
start, end = month_bounds(day)
return f"{day.year}-{day.month:02d}", f"{MONTHS_NOMINATIVE[day.month - 1]} {day.year}", start, end
if grain == GRAIN_YEAR:
start, end = year_bounds(day)
return str(day.year), f"{day.year} год", start, end
raise ValueError(f"Неизвестная детализация: {grain}")
def _span(start: date, end: date) -> str:
"""«1723 августа», «29 сентября 5 октября 2026»."""
if start.month == end.month:
return f"{start.day}{end.day} {MONTHS_GENITIVE[end.month - 1]} {end.year}"
left = f"{start.day} {MONTHS_GENITIVE[start.month - 1]}"
if start.year != end.year:
left += f" {start.year}"
return f"{left} {end.day} {MONTHS_GENITIVE[end.month - 1]} {end.year}"
# --- корзины --------------------------------------------------------------
def _units_text(quantities: dict[str, Decimal]) -> str:
"""Количества по единицам: «12 шт · 3,5 кг».
Единицы принципиально не складываются: сумма литров с килограммами
бессмысленное число, которое выглядит как настоящее.
"""
return " · ".join(
f"{m.fmt_qty(qty)} {unit}"
for unit, qty in sorted(quantities.items())
if qty > 0
)
@dataclass
class Bucket:
"""Сводка за отрезок времени."""
key: str
label: str
start: date
end: date
# По дате продажи.
revenue: Decimal = m.ZERO
cogs: Decimal = m.ZERO
margin: Decimal = m.ZERO
tips: Decimal = m.ZERO
consumed_cost: Decimal = m.ZERO
sales_count: int = 0
qty_by_unit: dict[str, Decimal] = field(default_factory=dict)
# По дате платежа.
collected: Decimal = m.ZERO
# По датам закупки и расчёта с пекарней.
purchased: Decimal = m.ZERO
paid_to_bakery: Decimal = m.ZERO
@property
def profit(self) -> Decimal:
"""Наценка плюс чаевые минус себестоимость съеденного и подаренного."""
return m.money(self.margin + self.tips - self.consumed_cost)
@property
def margin_pct(self) -> Decimal:
"""Наценка в процентах от выручки."""
if self.revenue <= 0:
return m.ZERO
return m.money(self.margin / self.revenue * 100)
@property
def qty_text(self) -> str:
return _units_text(self.qty_by_unit)
def add_qty(self, unit: str, qty: Decimal) -> None:
self.qty_by_unit[unit] = self.qty_by_unit.get(unit, m.ZERO) + qty
def build_buckets(doc: Document, report: ledger.Report, grain: str) -> list[Bucket]:
"""Разложить всё движение по корзинам выбранной детализации.
Пустые корзины между непустыми не создаются: если в марте не торговали,
строки за март не будет. Дырка в таблице честнее выдуманного нуля.
"""
buckets: dict[str, Bucket] = {}
def bucket_for(day: date) -> Bucket:
key, label, start, end = bucket_of(day, grain)
if key not in buckets:
buckets[key] = Bucket(key=key, label=label, start=start, end=end)
return buckets[key]
sales = {sale.id: sale for sale in doc.sales}
# Начисления — по дате продажи.
for allocation in report.allocations:
sale = sales.get(allocation.sale_id)
if sale is None:
continue
bucket = bucket_for(sale.date)
if allocation.kind in CONSUMPTION_KINDS:
bucket.consumed_cost += allocation.cost
else:
bucket.revenue += allocation.revenue
bucket.cogs += allocation.cost
bucket.margin += allocation.revenue - allocation.cost
for sale in doc.sales:
bucket = bucket_for(sale.date)
bucket.sales_count += 1
bucket.tips += sale.tip
for line in sale.lines:
product = doc.product(line.product_id)
bucket.add_qty(line.unit_name(product), line.base_qty)
# Деньги — по дате платежа: долг, возвращённый в сентябре, относится
# к сентябрю, а не к августовской продаже.
for payment in sale.payments:
bucket_for(payment.date).collected += payment.amount
# Чаевые отдают из рук в руки в момент продажи, своей даты у них нет.
bucket.collected += sale.tip
for batch in doc.batches:
bucket_for(batch.date).purchased += batch.cost_total
for payment in doc.bakery_payments:
bucket_for(payment.date).paid_to_bakery += payment.amount
return [_rounded(b) for b in sorted(buckets.values(), key=lambda b: b.start)]
def _rounded(bucket: Bucket) -> Bucket:
"""Округлить до копеек один раз в конце, а не на каждом слагаемом."""
for name in ("revenue", "cogs", "margin", "tips", "consumed_cost",
"collected", "purchased", "paid_to_bakery"):
setattr(bucket, name, m.money(getattr(bucket, name)))
bucket.qty_by_unit = {unit: m.qty(qty) for unit, qty in bucket.qty_by_unit.items()}
return bucket
def totals(buckets: list[Bucket], label: str = "Итого") -> Bucket:
"""Свернуть корзины в одну итоговую строку."""
if not buckets:
return Bucket(key="total", label=label, start=date.today(), end=date.today())
total = Bucket(key="total", label=label, start=buckets[0].start, end=buckets[-1].end)
for bucket in buckets:
total.revenue += bucket.revenue
total.cogs += bucket.cogs
total.margin += bucket.margin
total.tips += bucket.tips
total.consumed_cost += bucket.consumed_cost
total.collected += bucket.collected
total.purchased += bucket.purchased
total.paid_to_bakery += bucket.paid_to_bakery
total.sales_count += bucket.sales_count
for unit, qty in bucket.qty_by_unit.items():
total.add_qty(unit, qty)
return _rounded(total)
# --- разрез по партиям ----------------------------------------------------
@dataclass
class PeriodStat:
"""Партия как период: взял булки — верни себестоимость к сроку."""
report: ledger.BatchReport
margin: Decimal = m.ZERO
tips: Decimal = m.ZERO
# По единицам, а не одним числом: в партии может быть и сок в литрах,
# и булки в штуках, а их сумма — бессмыслица.
sold_by_unit: dict[str, Decimal] = field(default_factory=dict)
left_by_unit: dict[str, Decimal] = field(default_factory=dict)
@property
def batch(self):
return self.report.batch
@property
def sold_text(self) -> str:
return _units_text(self.sold_by_unit)
@property
def left_text(self) -> str:
return _units_text(self.left_by_unit)
@property
def label(self) -> str:
return self.report.label
@property
def profit(self) -> Decimal:
return m.money(self.margin + self.tips - self.report.consumed_cost)
@property
def days_total(self) -> int:
return (self.batch.due_date - self.batch.date).days
def build_periods(doc: Document, report: ledger.Report) -> list[PeriodStat]:
"""Статистика по заданным вручную периодам — то есть по партиям."""
margins: dict[str, Decimal] = {}
tips: dict[str, Decimal] = {}
sold: dict[str, dict[str, Decimal]] = {}
def unit_of(product_id: str) -> str:
product = doc.product(product_id)
return product.unit if product else "?"
for allocation in report.allocations:
by_unit = sold.setdefault(allocation.batch_id, {})
unit = unit_of(allocation.product_id)
by_unit[unit] = by_unit.get(unit, m.ZERO) + allocation.qty
if allocation.kind in CONSUMPTION_KINDS:
continue
margins[allocation.batch_id] = (
margins.get(allocation.batch_id, m.ZERO) + allocation.revenue - allocation.cost
)
tips[allocation.batch_id] = tips.get(allocation.batch_id, m.ZERO) + allocation.tip_share
result = []
for item in report.batches:
left: dict[str, Decimal] = {}
for product_id, qty in item.stock.items():
unit = unit_of(product_id)
left[unit] = left.get(unit, m.ZERO) + qty
result.append(
PeriodStat(
report=item,
margin=m.money(margins.get(item.batch.id, m.ZERO)),
tips=m.money(tips.get(item.batch.id, m.ZERO)),
sold_by_unit={u: m.qty(q) for u, q in sold.get(item.batch.id, {}).items()},
left_by_unit={u: m.qty(q) for u, q in left.items()},
)
)
return result
# --- разрез по товарам ----------------------------------------------------
@dataclass
class ProductStat:
product_id: str
name: str
unit: str
sold_qty: Decimal = m.ZERO
revenue: Decimal = m.ZERO
cogs: Decimal = m.ZERO
consumed_qty: Decimal = m.ZERO
consumed_cost: Decimal = m.ZERO
stock_qty: Decimal = m.ZERO
stock_cost: Decimal = m.ZERO
@property
def margin(self) -> Decimal:
return m.money(self.revenue - self.cogs)
@property
def margin_pct(self) -> Decimal:
if self.revenue <= 0:
return m.ZERO
return m.money(self.margin / self.revenue * 100)
def build_products(doc: Document, report: ledger.Report) -> list[ProductStat]:
"""Кто из булок кормит, а кто лежит мёртвым грузом."""
stats: dict[str, ProductStat] = {}
def stat_for(product_id: str) -> ProductStat:
if product_id not in stats:
product = doc.product(product_id)
stats[product_id] = ProductStat(
product_id=product_id,
name=doc.product_name(product_id),
unit=product.unit if product else "",
)
return stats[product_id]
for allocation in report.allocations:
stat = stat_for(allocation.product_id)
if allocation.kind in CONSUMPTION_KINDS:
stat.consumed_qty += allocation.qty
stat.consumed_cost += allocation.cost
else:
stat.sold_qty += allocation.qty
stat.revenue += allocation.revenue
stat.cogs += allocation.cost
for row in report.stock:
stat = stat_for(row.product_id)
stat.stock_qty = row.qty
stat.stock_cost = row.cost
for stat in stats.values():
stat.sold_qty = m.qty(stat.sold_qty)
stat.consumed_qty = m.qty(stat.consumed_qty)
stat.revenue = m.money(stat.revenue)
stat.cogs = m.money(stat.cogs)
stat.consumed_cost = m.money(stat.consumed_cost)
return sorted(stats.values(), key=lambda s: (-s.margin, s.name))
# --- разрез по людям ------------------------------------------------------
@dataclass
class PersonStat:
counterparty_id: str | None
name: str
sales_count: int = 0
revenue: Decimal = m.ZERO
paid: Decimal = m.ZERO
tips: Decimal = m.ZERO
debt: Decimal = m.ZERO
def build_people(doc: Document) -> list[PersonStat]:
"""Кто сколько взял, сколько занёс и сколько остался должен.
Продажи без контрагента идут одной строкой: это розница «с прилавка»,
и она тоже часть картины.
"""
stats: dict[str | None, PersonStat] = {}
for sale in doc.sales:
if sale.is_consumption:
continue
key = sale.counterparty_id
if key not in stats:
stats[key] = PersonStat(
counterparty_id=key,
name=doc.counterparty_name(key) if key else "Без контрагента",
)
stat = stats[key]
stat.sales_count += 1
stat.revenue += sale.total
stat.paid += sale.paid
stat.tips += sale.tip
stat.debt += sale.debt
for stat in stats.values():
stat.revenue = m.money(stat.revenue)
stat.paid = m.money(stat.paid)
stat.tips = m.money(stat.tips)
stat.debt = m.money(stat.debt)
return sorted(stats.values(), key=lambda s: (-s.revenue, s.name))

136
app/ui/chart.py Normal file
View File

@ -0,0 +1,136 @@
"""Столбчатый график на QPainter.
QtCharts исключён из сборки: он тянет за собой десятки мегабайт ради одной
диаграммы. Здесь нужен один тип графика, и нарисовать его вручную дешевле,
чем удвоить размер exe.
Умеет то, без чего график врал бы: отрицательные значения (убыток это
нормально) и нулевую линию на своём месте.
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPen
from PySide6.QtWidgets import QSizePolicy, QToolTip, QWidget
from .. import money as m
from . import theme
PADDING_LEFT = 8
PADDING_RIGHT = 8
PADDING_TOP = 18
LABEL_HEIGHT = 34
MIN_BAR_WIDTH = 14
BAR_GAP = 6
@dataclass
class Bar:
label: str
value: Decimal
tooltip: str = ""
class BarChart(QWidget):
"""Столбики со знаком. Подпись под каждым, значение — во всплывающей подсказке."""
def __init__(self, parent=None):
super().__init__(parent)
self.bars: list[Bar] = []
self.currency = ""
self.positive = QColor(theme.ACCENT)
self.negative = QColor(theme.DANGER)
self._rects: list[tuple[QRectF, Bar]] = []
self.setMinimumHeight(190)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setMouseTracking(True)
def set_bars(self, bars: list[Bar], currency: str = "", positive: str = "") -> None:
self.bars = list(bars)
self.currency = currency
if positive:
self.positive = QColor(positive)
self.update()
# --- отрисовка ---
def paintEvent(self, event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
self._rects.clear()
area = QRectF(self.rect()).adjusted(PADDING_LEFT, PADDING_TOP, -PADDING_RIGHT, -LABEL_HEIGHT)
if not self.bars or area.height() <= 0:
painter.setPen(QColor(theme.TEXT_DIM))
painter.drawText(self.rect(), Qt.AlignCenter, "Пока нечего показывать")
return
values = [float(bar.value) for bar in self.bars]
top = max(max(values), 0.0)
bottom = min(min(values), 0.0)
span = top - bottom
if span <= 0:
# Все значения нулевые: рисуем только базовую линию.
span, top = 1.0, 1.0
zero_y = area.bottom() - (0.0 - bottom) / span * area.height()
slot = area.width() / len(self.bars)
width = max(MIN_BAR_WIDTH, min(slot - BAR_GAP, 64.0))
self._draw_zero_line(painter, area, zero_y, bottom < 0)
metrics = QFontMetrics(painter.font())
for index, bar in enumerate(self.bars):
center = area.left() + slot * (index + 0.5)
height = abs(float(bar.value)) / span * area.height()
rect = QRectF(
center - width / 2,
zero_y - height if bar.value >= 0 else zero_y,
width,
max(height, 1.0),
)
color = self.positive if bar.value >= 0 else self.negative
painter.setPen(Qt.NoPen)
painter.setBrush(color)
painter.drawRoundedRect(rect, 3, 3)
self._rects.append((rect, bar))
self._draw_label(painter, metrics, bar, center, slot)
painter.end()
def _draw_zero_line(self, painter: QPainter, area: QRectF, zero_y: float, visible: bool) -> None:
pen = QPen(QColor(theme.BORDER))
pen.setStyle(Qt.SolidLine if visible else Qt.DotLine)
painter.setPen(pen)
painter.drawLine(QPointF(area.left(), zero_y), QPointF(area.right(), zero_y))
def _draw_label(self, painter: QPainter, metrics: QFontMetrics, bar: Bar, center: float, slot: float) -> None:
painter.setPen(QColor(theme.TEXT_DIM))
text = metrics.elidedText(bar.label, Qt.ElideRight, int(slot) - 2)
painter.drawText(
QRectF(center - slot / 2, self.height() - LABEL_HEIGHT + 4, slot, LABEL_HEIGHT - 6),
Qt.AlignHCenter | Qt.AlignTop,
text,
)
# --- подсказка ---
def mouseMoveEvent(self, event) -> None:
position = event.position()
for rect, bar in self._rects:
# Ловим по всей высоте столбца: попасть мышью в тонкую полоску
# у нуля невозможно.
if rect.left() <= position.x() <= rect.right():
QToolTip.showText(
event.globalPosition().toPoint(),
bar.tooltip or f"{bar.label}: {m.fmt_money(bar.value, self.currency)}",
self,
)
return
QToolTip.hideText()

View File

@ -33,6 +33,7 @@ from .page_journal import JournalPage
from .page_products import ProductsPage from .page_products import ProductsPage
from .page_sales import SalesPage from .page_sales import SalesPage
from .page_settings import SettingsPage from .page_settings import SettingsPage
from .page_stats import StatsPage
AUTOSAVE_MS = 2000 AUTOSAVE_MS = 2000
CLOSE_SYNC_WAIT_MS = 20_000 CLOSE_SYNC_WAIT_MS = 20_000
@ -110,6 +111,7 @@ class MainWindow(QMainWindow):
("Продажи", SalesPage(self)), ("Продажи", SalesPage(self)),
("Долги", DebtsPage(self)), ("Долги", DebtsPage(self)),
("Товары", ProductsPage(self)), ("Товары", ProductsPage(self)),
("Статистика", StatsPage(self)),
("Журнал", JournalPage(self)), ("Журнал", JournalPage(self)),
("Настройки", SettingsPage(self)), ("Настройки", SettingsPage(self)),
] ]

408
app/ui/page_stats.py Normal file
View File

@ -0,0 +1,408 @@
"""Статистика: периоды, недели, месяцы, годы, товары и люди.
Экран намеренно ничего не считает сам все цифры приходят из app/stats.py.
Здесь только показ: выбор разреза, график, таблица и строка итогов.
"""
from __future__ import annotations
from decimal import Decimal
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QTabWidget,
QVBoxLayout,
QWidget,
)
from .. import money as m
from .. import stats
from ..ledger import STATUS_LABELS
from . import theme
from . import widgets as w
from .chart import Bar, BarChart
# Сколько последних корзин показывать на графике. Дальше столбики становятся
# уже, чем подпись под ними, и картинка превращается в кашу.
CHART_LIMIT = 24
METRICS = [
("Прибыль", "profit", theme.OK),
("Выручка", "revenue", theme.ACCENT),
("Собрано деньгами", "collected", theme.ACCENT),
("Наценка", "margin", theme.OK),
("Чаевые", "tips", theme.OK),
("Закуплено", "purchased", theme.WARN),
("Отдано пекарне", "paid_to_bakery", theme.WARN),
]
BUCKET_COLUMNS = [
"Период", "Продаж", "Продано", "Выручка", "Себестоимость",
"Наценка", "Чаевые", "Съедено", "Прибыль", "Собрано",
"Закуплено", "Отдано пекарне",
]
def money_cell(value: Decimal, currency: str, color: str = "", dim_zero: bool = True):
"""Ячейка с суммой. Ноль приглушаем — глаз должен цепляться за цифры."""
if value == 0 and dim_zero:
return w.sortable_num_item("", 0, theme.MUTED)
return w.sortable_num_item(m.fmt_money(value, currency), value, color)
def signed_color(value: Decimal) -> str:
if value > 0:
return theme.OK
return theme.DANGER if value < 0 else ""
class BucketTab(QWidget):
"""Один временной разрез: график сверху, таблица снизу, итоги в конце."""
def __init__(self, ctx, grain: str, parent=None):
super().__init__(parent)
self.ctx = ctx
self.grain = grain
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
self.metric = QComboBox()
for title, attribute, color in METRICS:
self.metric.addItem(title, (attribute, color))
self.metric.currentIndexChanged.connect(self._draw_chart)
self.headline = w.label("", "h2")
layout.addWidget(w.row(w.label("На графике:"), self.metric, None, self.headline))
self.chart = BarChart()
layout.addWidget(self.chart)
self.table = w.table(BUCKET_COLUMNS)
self.table.setSortingEnabled(False) # хронология важнее сортировки
layout.addWidget(self.table, 1)
def refresh(self) -> None:
self.buckets = stats.build_buckets(self.ctx.vault.doc, self.ctx.report, self.grain)
currency = self.ctx.currency
rows = []
# Свежее сверху: в таблицу смотрят, чтобы увидеть последнее.
for bucket in reversed(self.buckets):
rows.append(self._row(bucket, currency))
if self.buckets:
total = stats.totals(self.buckets)
rows.append(self._row(total, currency, bold=True))
w.fill(self.table, rows)
self._draw_chart()
def _row(self, bucket: stats.Bucket, currency: str, bold: bool = False):
profit = bucket.profit
# Подписи недель длинные и в узкой колонке ужимаются многоточием —
# полный текст остаётся доступен наведением.
label = w.text_item(bucket.label, bold=bold)
label.setToolTip(f"{bucket.label}\n{bucket.start:%d.%m.%Y}{bucket.end:%d.%m.%Y}")
return [
label,
w.sortable_num_item(str(bucket.sales_count) if bucket.sales_count else "",
bucket.sales_count,
"" if bucket.sales_count else theme.MUTED, bold),
w.text_item(bucket.qty_text or "", "" if bucket.qty_text else theme.MUTED, bold),
money_cell(bucket.revenue, currency),
money_cell(bucket.cogs, currency),
money_cell(bucket.margin, currency, theme.OK if bucket.margin else ""),
money_cell(bucket.tips, currency, theme.OK if bucket.tips else ""),
money_cell(bucket.consumed_cost, currency, theme.DANGER if bucket.consumed_cost else ""),
money_cell(profit, currency, signed_color(profit), dim_zero=False),
money_cell(bucket.collected, currency),
money_cell(bucket.purchased, currency),
money_cell(bucket.paid_to_bakery, currency),
]
def _draw_chart(self) -> None:
attribute, color = self.metric.currentData()
shown = self.buckets[-CHART_LIMIT:]
currency = self.ctx.currency
self.chart.set_bars(
[
Bar(
label=self._short(bucket),
value=getattr(bucket, attribute),
tooltip=f"{bucket.label}\n{self.metric.currentText()}: "
f"{m.fmt_money(getattr(bucket, attribute), currency)}",
)
for bucket in shown
],
currency,
color,
)
total = sum((getattr(b, attribute) for b in self.buckets), m.ZERO)
hidden = len(self.buckets) - len(shown)
# Про срез говорим вслух: молча показанная часть выглядела бы как всё.
tail = f" · на графике последние {len(shown)} из {len(self.buckets)}" if hidden else ""
self.headline.setText(
f"{self.metric.currentText()} за всё время: {m.fmt_money(total, currency)}{tail}"
)
def _short(self, bucket: stats.Bucket) -> str:
if self.grain == stats.GRAIN_WEEK:
return bucket.start.strftime("%d.%m")
if self.grain == stats.GRAIN_MONTH:
return bucket.start.strftime("%m.%y")
return bucket.label.replace(" год", "")
class PeriodsTab(QWidget):
"""Периоды, которые задавались руками, — то есть партии."""
COLUMNS = [
"Партия", "Дата", "Покрыть до", "Дней", "Долг", "Отдано", "Собрано",
"Покрытие", "Продано", "Осталось", "Наценка", "Чаевые", "Съедено",
"Прибыль", "Статус",
]
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
self.headline = w.label("", "h2")
layout.addWidget(
w.row(w.label("Каждая закупка — период со своим сроком расчёта.", "dim"), None, self.headline)
)
self.chart = BarChart()
layout.addWidget(self.chart)
self.table = w.table(self.COLUMNS)
self.table.setSortingEnabled(False)
layout.addWidget(self.table, 1)
def refresh(self) -> None:
periods = stats.build_periods(self.ctx.vault.doc, self.ctx.report)
currency = self.ctx.currency
rows = []
for period in reversed(periods):
report = period.report
color = theme.STATUS_COLORS.get(report.status, "")
rows.append(
[
w.text_item(f"{period.batch.number}"),
w.text_item(period.batch.date.strftime("%d.%m.%Y")),
w.text_item(period.batch.due_date.strftime("%d.%m.%Y")),
w.sortable_num_item(str(period.days_total), period.days_total),
money_cell(report.cost_total, currency),
money_cell(report.paid_to_bakery, currency),
money_cell(report.cash_collected, currency),
w.sortable_num_item(f"{report.coverage_pct}%", report.coverage_pct, color),
w.text_item(period.sold_text or "",
"" if period.sold_text else theme.MUTED),
w.text_item(period.left_text or "",
"" if period.left_text else theme.MUTED),
money_cell(period.margin, currency, theme.OK if period.margin else ""),
money_cell(period.tips, currency, theme.OK if period.tips else ""),
money_cell(report.consumed_cost, currency, theme.DANGER if report.consumed_cost else ""),
money_cell(period.profit, currency, signed_color(period.profit), dim_zero=False),
w.text_item(STATUS_LABELS.get(report.status, ""), color),
]
)
w.fill(self.table, rows)
self.chart.set_bars(
[
Bar(
label=f"{p.batch.number}",
value=p.profit,
tooltip=f"Партия {p.label}\nПрибыль: {m.fmt_money(p.profit, currency)}\n"
f"Покрытие: {p.report.coverage_pct}%",
)
for p in periods[-CHART_LIMIT:]
],
currency,
theme.OK,
)
earned = sum((p.profit for p in periods), m.ZERO)
self.headline.setText(
f"Периодов: {len(periods)} · прибыль за все: {m.fmt_money(earned, currency)}"
)
class ProductsTab(QWidget):
"""Что кормит, а что лежит мёртвым грузом."""
COLUMNS = [
"Товар", "Продано", "Ед.", "Выручка", "Себестоимость", "Наценка",
"Наценка %", "Съедено", "На складе", "Склад по себестоимости",
]
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
self.headline = w.label("", "h2")
layout.addWidget(w.row(w.label("Отсортировано по заработку.", "dim"), None, self.headline))
self.chart = BarChart()
layout.addWidget(self.chart)
self.table = w.table(self.COLUMNS)
# Порядок в таблице должен совпадать с графиком и с подписью над ним.
# Без этого таблица молча пересортировывается по названию, и рядом
# оказываются два разных ответа на один вопрос «кто больше приносит».
self.table.sortByColumn(self.COLUMNS.index("Наценка"), Qt.DescendingOrder)
layout.addWidget(self.table, 1)
def refresh(self) -> None:
rows_data = stats.build_products(self.ctx.vault.doc, self.ctx.report)
currency = self.ctx.currency
rows = []
for item in rows_data:
rows.append(
[
w.text_item(item.name),
w.sortable_num_item(m.fmt_qty(item.sold_qty), item.sold_qty),
w.text_item(item.unit),
money_cell(item.revenue, currency),
money_cell(item.cogs, currency),
money_cell(item.margin, currency, signed_color(item.margin), dim_zero=False),
w.sortable_num_item(
f"{item.margin_pct}%" if item.revenue else "",
item.margin_pct,
"" if item.revenue else theme.MUTED,
),
money_cell(item.consumed_cost, currency, theme.DANGER if item.consumed_cost else ""),
w.sortable_num_item(m.fmt_qty(item.stock_qty), item.stock_qty),
money_cell(item.stock_cost, currency),
]
)
w.fill(self.table, rows)
self.chart.set_bars(
[
Bar(
label=item.name,
value=item.margin,
tooltip=f"{item.name}\nНаценка: {m.fmt_money(item.margin, currency)}\n"
f"Продано: {m.fmt_qty(item.sold_qty)} {item.unit}",
)
for item in rows_data[:CHART_LIMIT]
],
currency,
theme.OK,
)
earned = sum((item.margin for item in rows_data), m.ZERO)
self.headline.setText(f"Наценка по всем товарам: {m.fmt_money(earned, currency)}")
class PeopleTab(QWidget):
"""Кто сколько взял и сколько остался должен."""
COLUMNS = ["Кто", "Покупок", "Выручка", "Оплачено", "Чаевые", "Долг"]
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(10)
self.headline = w.label("", "h2")
layout.addWidget(w.row(w.label("Отсортировано по выручке.", "dim"), None, self.headline))
self.chart = BarChart()
layout.addWidget(self.chart)
self.table = w.table(self.COLUMNS)
self.table.sortByColumn(self.COLUMNS.index("Выручка"), Qt.DescendingOrder)
layout.addWidget(self.table, 1)
def refresh(self) -> None:
people = stats.build_people(self.ctx.vault.doc)
currency = self.ctx.currency
rows = []
for person in people:
rows.append(
[
w.text_item(person.name),
w.sortable_num_item(str(person.sales_count), person.sales_count),
money_cell(person.revenue, currency),
money_cell(person.paid, currency),
money_cell(person.tips, currency, theme.OK if person.tips else ""),
money_cell(person.debt, currency, theme.WARN if person.debt else ""),
]
)
w.fill(self.table, rows)
self.chart.set_bars(
[
Bar(
label=person.name,
value=person.revenue,
tooltip=f"{person.name}\nВыручка: {m.fmt_money(person.revenue, currency)}\n"
f"Долг: {m.fmt_money(person.debt, currency)}",
)
for person in people[:CHART_LIMIT]
],
currency,
theme.ACCENT,
)
owed = sum((person.debt for person in people), m.ZERO)
self.headline.setText(
f"Покупателей: {len(people)} · должны в сумме: {m.fmt_money(owed, currency)}"
)
class StatsPage(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
self.ctx = ctx
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 18, 20, 8)
layout.setSpacing(10)
layout.addWidget(w.heading("Статистика"))
self.tabs = QTabWidget()
self.sections = [
("Периоды", PeriodsTab(ctx)),
("Недели", BucketTab(ctx, stats.GRAIN_WEEK)),
("Месяцы", BucketTab(ctx, stats.GRAIN_MONTH)),
("Годы", BucketTab(ctx, stats.GRAIN_YEAR)),
("Товары", ProductsTab(ctx)),
("Люди", PeopleTab(ctx)),
]
for title, tab in self.sections:
self.tabs.addTab(tab, title)
# Пересчитывать все шесть разрезов на каждое изменение базы незачем:
# видно всё равно один.
self.tabs.currentChanged.connect(lambda _: self._refresh_current())
layout.addWidget(self.tabs, 1)
def refresh(self) -> None:
self._refresh_current()
def _refresh_current(self) -> None:
index = self.tabs.currentIndex()
if 0 <= index < len(self.sections):
self.sections[index][1].refresh()

438
tests/test_stats.py Normal file
View File

@ -0,0 +1,438 @@
"""Сводные цифры по периодам, товарам и людям."""
from datetime import date, timedelta
from decimal import Decimal
import pytest
from app import journal, ledger, stats
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF
def line(product, qty, price):
return {"product_id": product.id, "qty": qty, "unit_price": price}
def buy(product, qty, cost):
return {"product_id": product.id, "qty": qty, "unit_cost": cost}
def report_for(vault, today=None):
return ledger.build(vault.doc, today or date.today())
# --- границы периодов -----------------------------------------------------
def test_week_bounds_run_monday_to_sunday():
# 19 августа 2026 — среда.
start, end = stats.week_bounds(date(2026, 8, 19))
assert (start, end) == (date(2026, 8, 17), date(2026, 8, 23))
assert start.isoweekday() == 1 and end.isoweekday() == 7
def test_month_bounds():
assert stats.month_bounds(date(2026, 2, 14)) == (date(2026, 2, 1), date(2026, 2, 28))
assert stats.month_bounds(date(2024, 2, 14)) == (date(2024, 2, 1), date(2024, 2, 29))
assert stats.month_bounds(date(2026, 12, 5)) == (date(2026, 12, 1), date(2026, 12, 31))
def test_year_bounds():
assert stats.year_bounds(date(2026, 7, 7)) == (date(2026, 1, 1), date(2026, 12, 31))
def test_labels_are_readable():
_, week, _, _ = stats.bucket_of(date(2026, 8, 19), stats.GRAIN_WEEK)
assert week == "Неделя 34 · 1723 августа 2026"
_, month, _, _ = stats.bucket_of(date(2026, 8, 19), stats.GRAIN_MONTH)
assert month == "Август 2026"
_, year, _, _ = stats.bucket_of(date(2026, 8, 19), stats.GRAIN_YEAR)
assert year == "2026 год"
def test_week_label_spanning_two_months():
_, label, _, _ = stats.bucket_of(date(2026, 10, 1), stats.GRAIN_WEEK)
assert label == "Неделя 40 · 28 сентября 4 октября 2026"
def test_week_label_spanning_two_years():
_, label, _, _ = stats.bucket_of(date(2026, 12, 31), stats.GRAIN_WEEK)
assert "2026" in label and "2027" in label
def test_keys_sort_chronologically():
keys = [
stats.bucket_of(date(2026, 1, 5), stats.GRAIN_WEEK)[0],
stats.bucket_of(date(2026, 10, 5), stats.GRAIN_WEEK)[0],
]
assert keys == sorted(keys) # W02 против W41, а не «10» перед «2»
def test_unknown_grain_is_rejected():
with pytest.raises(ValueError):
stats.bucket_of(date.today(), "квартал")
# --- корзины --------------------------------------------------------------
@pytest.fixture
def three_months(vault, buns):
"""Три месяца торговли с долгом, чаевыми и съеденным."""
june, july, august = date(2026, 6, 10), date(2026, 7, 10), date(2026, 8, 10)
journal.create_batch(vault, june, june + timedelta(days=20), [buy(buns["мак"], 100, 20)])
journal.create_sale(vault, june, KIND_RETAIL, [line(buns["мак"], 10, 35)])
journal.create_sale(vault, july, KIND_RETAIL, [line(buns["мак"], 20, 35)], tip=50)
journal.create_sale(vault, july, KIND_SELF, [line(buns["мак"], 2, 0)])
journal.create_sale(vault, august, KIND_FRIEND, [line(buns["мак"], 5, 20)])
return vault
def buckets_by_key(vault, grain, today=None):
result = stats.build_buckets(vault.doc, report_for(vault, today), grain)
return {b.key: b for b in result}
def test_months_are_split_correctly(three_months):
months = buckets_by_key(three_months, stats.GRAIN_MONTH, date(2026, 8, 31))
assert list(months) == ["2026-06", "2026-07", "2026-08"]
june = months["2026-06"]
assert june.revenue == Decimal("350.00") # 10 × 35
assert june.cogs == Decimal("200.00") # 10 × 20
assert june.margin == Decimal("150.00")
assert june.purchased == Decimal("2000.00") # партия взята в июне
def test_tips_and_consumption_land_in_the_right_month(three_months):
july = buckets_by_key(three_months, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-07"]
assert july.tips == Decimal("50.00")
assert july.consumed_cost == Decimal("40.00") # 2 × 20
assert july.margin == Decimal("300.00") # 20 × 15
assert july.profit == Decimal("310.00") # 300 + 50 40
def test_friend_price_gives_no_margin(three_months):
august = buckets_by_key(three_months, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert august.revenue == Decimal("100.00")
assert august.margin == 0
assert august.profit == 0
def test_empty_months_are_skipped(vault, buns):
"""В марте не торговали — выдуманного нуля в таблице быть не должно."""
journal.create_batch(vault, date(2026, 1, 5), date(2026, 1, 20), [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, date(2026, 4, 5), KIND_RETAIL, [line(buns["мак"], 1, 35)])
keys = list(buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 5, 1)))
assert keys == ["2026-01", "2026-04"]
def test_years_roll_up_months(three_months, buns):
"""Год обязан быть в точности суммой своих месяцев."""
journal.create_sale(
three_months, date(2025, 12, 20), KIND_RETAIL, [line(buns["мак"], 1, 35)]
)
report = report_for(three_months, date(2026, 12, 31))
years = {b.key: b for b in stats.build_buckets(three_months.doc, report, stats.GRAIN_YEAR)}
months = stats.build_buckets(three_months.doc, report, stats.GRAIN_MONTH)
assert list(years) == ["2025", "2026"]
of_2026 = [b for b in months if b.start.year == 2026]
assert years["2026"].revenue == sum(b.revenue for b in of_2026)
assert years["2026"].profit == sum(b.profit for b in of_2026)
assert years["2026"].sales_count == sum(b.sales_count for b in of_2026)
# 10×35 в июне + 20×35 в июле + 5×20 другу в августе
assert years["2026"].revenue == Decimal("1150.00")
def test_weeks_split_by_iso_week(vault, buns):
monday, sunday = date(2026, 8, 17), date(2026, 8, 23)
journal.create_batch(vault, monday, sunday, [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, monday, KIND_RETAIL, [line(buns["мак"], 1, 35)])
journal.create_sale(vault, sunday, KIND_RETAIL, [line(buns["мак"], 1, 35)])
journal.create_sale(vault, sunday + timedelta(days=1), KIND_RETAIL, [line(buns["мак"], 1, 35)])
weeks = buckets_by_key(vault, stats.GRAIN_WEEK, date(2026, 9, 1))
assert weeks["2026-W34"].sales_count == 2
assert weeks["2026-W35"].sales_count == 1
# --- касса против начислений ----------------------------------------------
def test_money_lands_in_the_month_it_arrived(vault, buns):
"""Долг за август, возвращённый в сентябре, — сентябрьские деньги."""
august, september = date(2026, 8, 10), date(2026, 9, 5)
journal.create_batch(vault, august, august + timedelta(days=20), [buy(buns["мак"], 50, 20)])
sale = journal.create_sale(
vault, august, KIND_RETAIL, [line(buns["мак"], 10, 35)], paid_amount=0
)
journal.add_sale_payment(vault, sale.id, 350, september)
months = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 9, 30))
assert months["2026-08"].revenue == Decimal("350.00") # наторговал в августе
assert months["2026-08"].collected == 0 # но денег в августе не видел
assert months["2026-09"].collected == Decimal("350.00")
assert months["2026-09"].revenue == 0
def test_bakery_payments_land_by_their_own_date(vault, buns):
august = date(2026, 8, 10)
batch = journal.create_batch(
vault, august, august + timedelta(days=20), [buy(buns["мак"], 50, 20)]
)
journal.add_bakery_payment(vault, 400, date(2026, 9, 3), batch_id=batch.id)
months = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 9, 30))
assert months["2026-08"].purchased == Decimal("1000.00")
assert months["2026-08"].paid_to_bakery == 0
assert months["2026-09"].paid_to_bakery == Decimal("400.00")
def test_tips_count_as_money_at_sale_time(vault, buns):
"""Чаевые дают из рук в руки, своей даты у них нет."""
day = date(2026, 8, 10)
journal.create_batch(vault, day, day + timedelta(days=14), [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, day, KIND_RETAIL, [line(buns["мак"], 2, 35)], paid_amount=0, tip=30)
month = buckets_by_key(vault, stats.GRAIN_MONTH, date(2026, 8, 31))["2026-08"]
assert month.collected == Decimal("30.00")
# --- количества и проценты ------------------------------------------------
def test_quantities_are_not_mixed_across_units(vault, buns, today, in_two_weeks):
juice = journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 10, 20), {"product_id": juice.id, "qty": 5, "unit_cost": 80}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 3, 35)])
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "1.5", 120)])
bucket = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)[0]
assert bucket.qty_by_unit == {"шт": Decimal("3.000"), "л": Decimal("1.500")}
assert bucket.qty_text == "1,5 л · 3 шт"
def test_packs_are_counted_in_base_units(vault, today, in_two_weeks):
cookies = journal.create_product(vault, "Печенье", cost_price=20, retail_price=30)
journal.set_packs(vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": cookies.id, "qty": 5, "uom": "пачка", "unit_cost": 200}],
)
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": cookies.id, "qty": 2, "uom": "пачка", "unit_price": 200}],
)
bucket = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)[0]
assert bucket.qty_by_unit == {"пачка": Decimal("20.000")}
def test_margin_percentage(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)])
bucket = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)[0]
assert bucket.margin_pct == Decimal("42.86") # 150 из 350
def test_margin_percentage_without_revenue_is_zero(vault):
assert stats.Bucket(key="x", label="x", start=date.today(), end=date.today()).margin_pct == 0
# --- итоги ----------------------------------------------------------------
def test_totals_sum_every_bucket(three_months):
buckets = stats.build_buckets(three_months.doc, report_for(three_months, date(2026, 8, 31)), stats.GRAIN_MONTH)
total = stats.totals(buckets)
assert total.revenue == sum(b.revenue for b in buckets)
assert total.profit == sum(b.profit for b in buckets)
assert total.sales_count == 4
assert total.start == buckets[0].start
assert total.end == buckets[-1].end
def test_totals_of_nothing_are_zero(vault):
total = stats.totals([])
assert total.revenue == 0 and total.profit == 0 and total.sales_count == 0
def test_totals_keep_units_apart(vault, buns, today, in_two_weeks):
juice = journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 10, 20), {"product_id": juice.id, "qty": 5, "unit_cost": 80}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 3, 35)])
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, 2, 120)])
buckets = stats.build_buckets(vault.doc, report_for(vault, today), stats.GRAIN_MONTH)
total = stats.totals(buckets)
assert total.qty_by_unit == {"шт": Decimal("3.000"), "л": Decimal("2.000")}
# --- разрез по партиям ----------------------------------------------------
def test_periods_carry_batch_economics(vault, buns, today, in_two_weeks):
batch = journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)], tip=25)
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 1, 0)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.batch.id == batch.id
assert period.margin == Decimal("60.00") # 4 × 15
assert period.tips == Decimal("25.00")
assert period.report.consumed_cost == Decimal("20.00")
assert period.profit == Decimal("65.00") # 60 + 25 20
assert period.days_total == 14
def test_period_tips_split_between_batches(vault, buns, today):
first = journal.create_batch(
vault, today - timedelta(days=5), today + timedelta(days=9), [buy(buns["мак"], 3, 20)]
)
second = journal.create_batch(
vault, today, today + timedelta(days=14), [buy(buns["мак"], 7, 20)]
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)], tip=40)
periods = {p.batch.id: p for p in stats.build_periods(vault.doc, report_for(vault, today))}
assert periods[first.id].tips == Decimal("30.00") # три штуки из четырёх
assert periods[second.id].tips == Decimal("10.00")
def test_period_quantities_keep_units_apart(vault, buns, today, in_two_weeks):
"""В партии и сок в литрах, и булки в штуках — складывать их нельзя."""
juice = journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120)
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 10, 20), {"product_id": juice.id, "qty": 6, "unit_cost": 80}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)])
journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "1.5", 120)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.sold_by_unit == {"шт": Decimal("4.000"), "л": Decimal("1.500")}
assert period.left_by_unit == {"шт": Decimal("6.000"), "л": Decimal("4.500")}
assert period.sold_text == "1,5 л · 4 шт"
assert period.left_text == "4,5 л · 6 шт"
def test_period_quantities_include_what_was_eaten(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 3, 35)])
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 2, 0)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.sold_by_unit == {"шт": Decimal("5.000")} # выбыло всего
assert period.left_by_unit == {"шт": Decimal("5.000")}
def test_units_text_hides_empty_units(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.sold_text == "" # ничего не продано — и нечего показывать
assert period.left_text == "10 шт"
def test_periods_are_listed_even_without_sales(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
period = stats.build_periods(vault.doc, report_for(vault, today))[0]
assert period.margin == 0
assert period.report.cost_total == Decimal("200.00")
# --- разрез по товарам ----------------------------------------------------
def test_products_ranked_by_margin(vault, buns, today, in_two_weeks):
journal.create_batch(
vault, today, in_two_weeks,
[buy(buns["мак"], 20, 20), buy(buns["повидло"], 20, 30)],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)]) # 30
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["повидло"], 10, 50)]) # 200
rows = stats.build_products(vault.doc, report_for(vault, today))
assert [r.name for r in rows][:2] == ["Булка с повидлом", "Булка с маком"]
assert rows[0].margin == Decimal("200.00")
assert rows[0].sold_qty == Decimal("10.000")
assert rows[0].margin_pct == Decimal("40.00") # 200 из 500
def test_products_separate_sold_from_eaten(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 20, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 5, 35)])
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 3, 0)])
row = next(r for r in stats.build_products(vault.doc, report_for(vault, today)) if r.name == "Булка с маком")
assert row.sold_qty == Decimal("5.000")
assert row.consumed_qty == Decimal("3.000")
assert row.consumed_cost == Decimal("60.00")
assert row.stock_qty == Decimal("12.000")
def test_products_show_untouched_stock(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["корица"], 8, 25)])
row = next(r for r in stats.build_products(vault.doc, report_for(vault, today)) if r.name == "Булка с корицей")
assert row.sold_qty == 0
assert row.stock_qty == Decimal("8.000")
assert row.stock_cost == Decimal("200.00")
# --- разрез по людям ------------------------------------------------------
def test_people_ranked_by_revenue(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 50, 20)])
vasya = journal.create_counterparty(vault, "Вася")
petya = journal.create_counterparty(vault, "Петя")
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)], counterparty_id=vasya.id)
journal.create_sale(
vault, today, KIND_RETAIL, [line(buns["мак"], 10, 35)],
counterparty_id=petya.id, paid_amount=100, tip=20,
)
rows = stats.build_people(vault.doc)
assert [r.name for r in rows] == ["Петя", "Вася"]
assert rows[0].revenue == Decimal("350.00")
assert rows[0].paid == Decimal("100.00")
assert rows[0].tips == Decimal("20.00")
assert rows[0].debt == Decimal("250.00")
assert rows[0].sales_count == 1
def test_walk_in_sales_grouped_separately(vault, buns, today, in_two_weeks):
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 2, 35)])
rows = stats.build_people(vault.doc)
assert [r.name for r in rows] == ["Без контрагента"]
def test_consumption_is_not_a_person(vault, buns, today, in_two_weeks):
"""Съел сам — это не покупатель, в списке людей ему не место."""
journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 50, 20)])
journal.create_sale(vault, today, KIND_SELF, [line(buns["мак"], 2, 0)])
assert stats.build_people(vault.doc) == []
def test_empty_database_produces_empty_sections(vault, today):
report = report_for(vault, today)
assert stats.build_buckets(vault.doc, report, stats.GRAIN_MONTH) == []
assert stats.build_periods(vault.doc, report) == []
assert stats.build_products(vault.doc, report) == []
assert stats.build_people(vault.doc) == []

View File

@ -70,10 +70,22 @@ def window(qapp, vault, buns, today, in_two_weeks):
# --- окно целиком --------------------------------------------------------- # --- окно целиком ---------------------------------------------------------
def page_of(window, title: str):
"""Найти экран по названию.
Раньше тесты адресовали экраны по номеру в списке, и любой новый раздел
в боковом меню ломал половину проверок.
"""
return next(page for name, page in window.pages if name == title)
def test_window_builds_with_all_pages(window): def test_window_builds_with_all_pages(window):
titles = [title for title, _ in window.pages] titles = [title for title, _ in window.pages]
assert titles == ["Сводка", "Закупки", "Продажи", "Долги", "Товары", "Журнал", "Настройки"] assert titles == [
assert window.stack.count() == 7 "Сводка", "Закупки", "Продажи", "Долги",
"Товары", "Статистика", "Журнал", "Настройки",
]
assert window.stack.count() == len(titles)
def test_every_page_refreshes_without_errors(window): def test_every_page_refreshes_without_errors(window):
@ -137,7 +149,7 @@ def test_no_other_place_builds_git_by_hand():
def test_batches_table_shows_the_batch(window): def test_batches_table_shows_the_batch(window):
page = window.pages[1][1] page = page_of(window, "Закупки")
page.refresh() page.refresh()
assert page.table.rowCount() == 1 assert page.table.rowCount() == 1
assert page.table.item(0, 0).text() == "1" assert page.table.item(0, 0).text() == "1"
@ -145,7 +157,7 @@ def test_batches_table_shows_the_batch(window):
def test_batch_details_show_coverage(window): def test_batch_details_show_coverage(window):
page = window.pages[1][1] page = page_of(window, "Закупки")
page.refresh() page.refresh()
page.table.selectRow(0) page.table.selectRow(0)
page._show_details() page._show_details()
@ -156,20 +168,20 @@ def test_batch_details_show_coverage(window):
def test_sales_table_lists_every_kind(window): def test_sales_table_lists_every_kind(window):
page = window.pages[2][1] page = page_of(window, "Продажи")
page.refresh() page.refresh()
assert page.table.rowCount() == 3 assert page.table.rowCount() == 3
def test_sales_filter_narrows_the_list(window): def test_sales_filter_narrows_the_list(window):
page = window.pages[2][1] page = page_of(window, "Продажи")
page.filter.setCurrentIndex(page.filter.findData(KIND_SELF)) page.filter.setCurrentIndex(page.filter.findData(KIND_SELF))
page.refresh() page.refresh()
assert page.table.rowCount() == 1 assert page.table.rowCount() == 1
def test_debts_tree_groups_by_person(window): def test_debts_tree_groups_by_person(window):
page = window.pages[3][1] page = page_of(window, "Долги")
page.refresh() page.refresh()
assert page.tree.topLevelItemCount() == 1 assert page.tree.topLevelItemCount() == 1
@ -180,7 +192,7 @@ def test_debts_tree_groups_by_person(window):
def test_products_table_shows_stock_and_margin(window): def test_products_table_shows_stock_and_margin(window):
page = window.pages[4][1] page = page_of(window, "Товары")
page.refresh() page.refresh()
names = [page.table.item(r, 0).text() for r in range(page.table.rowCount())] names = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
assert "Булка с маком" in names assert "Булка с маком" in names
@ -191,7 +203,7 @@ def test_products_table_shows_stock_and_margin(window):
def test_archived_products_are_hidden_by_default(window, buns): def test_archived_products_are_hidden_by_default(window, buns):
page = window.pages[4][1] page = page_of(window, "Товары")
journal.update_product(window.vault, buns["мак"].id, archived=True) journal.update_product(window.vault, buns["мак"].id, archived=True)
window.changed() window.changed()
@ -209,7 +221,7 @@ def test_archived_products_are_hidden_by_default(window, buns):
def test_dashboard_renders_metrics_and_deadline(window): def test_dashboard_renders_metrics_and_deadline(window):
from PySide6.QtWidgets import QLabel, QProgressBar from PySide6.QtWidgets import QLabel, QProgressBar
page = window.pages[0][1] page = page_of(window, "Сводка")
page.refresh() page.refresh()
texts = " ".join(label.text() for label in page.body.findChildren(QLabel)) texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
@ -227,7 +239,7 @@ def test_dashboard_renders_metrics_and_deadline(window):
def test_dashboard_shows_a_warning_when_oversold(window, buns): def test_dashboard_shows_a_warning_when_oversold(window, buns):
page = window.pages[0][1] page = page_of(window, "Сводка")
journal.create_sale( journal.create_sale(
window.vault, window.report.today, KIND_RETAIL, window.vault, window.report.today, KIND_RETAIL,
[{"product_id": buns["повидло"].id, "qty": 99, "unit_price": 50}], [{"product_id": buns["повидло"].id, "qty": 99, "unit_price": 50}],
@ -240,13 +252,13 @@ def test_dashboard_shows_a_warning_when_oversold(window, buns):
def test_journal_page_lists_entries(window): def test_journal_page_lists_entries(window):
page = window.pages[5][1] page = page_of(window, "Журнал")
page.refresh() page.refresh()
assert page.table.rowCount() == len(window.vault.doc.journal) assert page.table.rowCount() == len(window.vault.doc.journal)
def test_journal_search_filters(window, buns): def test_journal_search_filters(window, buns):
page = window.pages[5][1] page = page_of(window, "Журнал")
journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40) journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40)
window.changed() window.changed()
@ -258,7 +270,7 @@ def test_journal_search_filters(window, buns):
def test_journal_search_matches_what_is_displayed(window, buns): def test_journal_search_matches_what_is_displayed(window, buns):
"""Формат на экране и то, что ищется, должны совпадать до пробела.""" """Формат на экране и то, что ищется, должны совпадать до пробела."""
page = window.pages[5][1] page = page_of(window, "Журнал")
journal.change_prices(window.vault, buns["мак"].id, cost_price=1500, retail_price=2000) journal.change_prices(window.vault, buns["мак"].id, cost_price=1500, retail_price=2000)
window.changed() window.changed()
@ -268,7 +280,7 @@ def test_journal_search_matches_what_is_displayed(window, buns):
def test_journal_entity_filter(window): def test_journal_entity_filter(window):
page = window.pages[5][1] page = page_of(window, "Журнал")
page.entity.setCurrentIndex(page.entity.findData("batch")) page.entity.setCurrentIndex(page.entity.findData("batch"))
page.refresh() page.refresh()
assert page.table.rowCount() == 1 assert page.table.rowCount() == 1
@ -449,6 +461,144 @@ def test_lines_editor_add_and_remove(window, buns):
dialog.deleteLater() dialog.deleteLater()
# --- статистика -----------------------------------------------------------
def test_stats_page_has_every_section(window):
page = page_of(window, "Статистика")
titles = [page.tabs.tabText(i) for i in range(page.tabs.count())]
assert titles == ["Периоды", "Недели", "Месяцы", "Годы", "Товары", "Люди"]
def test_stats_sections_all_render(window):
page = page_of(window, "Статистика")
for index in range(page.tabs.count()):
page.tabs.setCurrentIndex(index)
page.refresh()
assert page.sections[index][1].table.rowCount() >= 0
def test_stats_periods_show_the_batch(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(0)
page.refresh()
tab = page.sections[0][1]
assert tab.table.rowCount() == 1
assert tab.table.item(0, 0).text() == "№1"
assert "850" in tab.table.item(0, 4).text() # долг по партии
assert "Периодов: 1" in tab.headline.text()
def test_stats_months_carry_a_totals_row(window):
"""Последняя строка — итог, иначе цифры приходится складывать в уме."""
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(2)
page.refresh()
tab = page.sections[2][1]
assert tab.table.rowCount() == len(tab.buckets) + 1
assert tab.table.item(tab.table.rowCount() - 1, 0).text() == "Итого"
def test_stats_metric_switch_redraws_the_chart(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(2)
page.refresh()
tab = page.sections[2][1]
tab.metric.setCurrentIndex(1) # Выручка
assert "Выручка за всё время" in tab.headline.text()
assert len(tab.chart.bars) == len(tab.buckets)
def test_stats_products_rank_by_margin(window):
"""Таблица и график должны отвечать одинаково: сортировка по заработку."""
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(4)
page.refresh()
tab = page.sections[4][1]
names = [tab.table.item(r, 0).text() for r in range(tab.table.rowCount())]
assert names[0] == "Булка с повидлом" # единственная с наценкой
assert names[0] == tab.chart.bars[0].label
def test_stats_people_table_matches_its_chart(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(5)
page.refresh()
tab = page.sections[5][1]
names = [tab.table.item(r, 0).text() for r in range(tab.table.rowCount())]
assert names == [bar.label for bar in tab.chart.bars]
def test_stats_people_show_the_debt(window):
page = page_of(window, "Статистика")
page.tabs.setCurrentIndex(5)
page.refresh()
tab = page.sections[5][1]
rows = {tab.table.item(r, 0).text(): r for r in range(tab.table.rowCount())}
assert "100,00 ₽" in tab.table.item(rows["Вася"], 5).text()
assert "должны в сумме: 100,00 ₽" in tab.headline.text()
def test_stats_survive_an_empty_database(qapp, vault):
"""Пустая база не должна ронять ни один разрез."""
from app.ui.main_window import MainWindow as MW
win = MW(vault)
win.autosave_timer.stop()
win.sync_timer.stop()
page = next(p for name, p in win.pages if name == "Статистика")
for index in range(page.tabs.count()):
page.tabs.setCurrentIndex(index)
page.refresh()
assert page.sections[index][1].table.rowCount() == 0
win.close()
win.deleteLater()
def test_chart_handles_losses(qapp):
"""Убыток — нормальная величина, столбик должен уходить вниз от нуля."""
from decimal import Decimal as D
from app.ui.chart import Bar, BarChart
chart = BarChart()
chart.set_bars([Bar("янв", D("100")), Bar("фев", D("-40")), Bar("мар", D("0"))])
chart.resize(400, 200)
chart.grab() # отрисовка не должна падать
assert len(chart.bars) == 3
chart.deleteLater()
def test_chart_handles_all_zeroes(qapp):
from decimal import Decimal as D
from app.ui.chart import Bar, BarChart
chart = BarChart()
chart.set_bars([Bar("янв", D("0")), Bar("фев", D("0"))])
chart.resize(400, 200)
chart.grab()
chart.deleteLater()
def test_empty_chart_says_so(qapp):
from app.ui.chart import BarChart
chart = BarChart()
chart.resize(400, 200)
chart.grab()
assert chart.bars == []
chart.deleteLater()
# --- чаевые --------------------------------------------------------------- # --- чаевые ---------------------------------------------------------------
@ -509,7 +659,7 @@ def test_sales_table_shows_tips(window, buns):
journal.set_sale_tip(window.vault, sale.id, 25) journal.set_sale_tip(window.vault, sale.id, 25)
window.changed() window.changed()
page = window.pages[2][1] page = page_of(window, "Продажи")
page.refresh() page.refresh()
tips = [page.table.item(r, 6).text() for r in range(page.table.rowCount())] tips = [page.table.item(r, 6).text() for r in range(page.table.rowCount())]
assert any("25,00" in t for t in tips) assert any("25,00" in t for t in tips)
@ -520,7 +670,7 @@ def test_sales_table_shows_tips(window, buns):
def test_dashboard_mentions_tips_only_when_there_are_any(window, buns): def test_dashboard_mentions_tips_only_when_there_are_any(window, buns):
from PySide6.QtWidgets import QLabel from PySide6.QtWidgets import QLabel
page = window.pages[0][1] page = page_of(window, "Сводка")
page.refresh() page.refresh()
texts = " ".join(label.text() for label in page.body.findChildren(QLabel)) texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
assert "чаевые" not in texts.lower() assert "чаевые" not in texts.lower()
@ -623,7 +773,7 @@ def test_fractional_quantity_is_displayed_without_trailing_zeros(window):
) )
window.changed() window.changed()
page = window.pages[2][1] page = page_of(window, "Продажи")
page.refresh() page.refresh()
texts = [page.table.item(r, 3).text() for r in range(page.table.rowCount())] texts = [page.table.item(r, 3).text() for r in range(page.table.rowCount())]
assert any("Сок × 0,5 л" in t for t in texts) assert any("Сок × 0,5 л" in t for t in texts)
@ -633,7 +783,7 @@ def test_fractional_quantity_is_displayed_without_trailing_zeros(window):
def test_settings_shows_database_facts(window): def test_settings_shows_database_facts(window):
page = window.pages[6][1] page = page_of(window, "Настройки")
page.refresh() page.refresh()
assert "товаров 3" in page.db_info.text() assert "товаров 3" in page.db_info.text()
assert "закупок 1" in page.db_info.text() assert "закупок 1" in page.db_info.text()
@ -641,7 +791,7 @@ def test_settings_shows_database_facts(window):
def test_settings_saves_git_without_wiping_the_token(window): def test_settings_saves_git_without_wiping_the_token(window):
"""Пустое поле токена означает «не трогать», а не «стереть».""" """Пустое поле токена означает «не трогать», а не «стереть»."""
page = window.pages[6][1] page = page_of(window, "Настройки")
journal.update_settings(window.vault, git={"token": "glpat-keepme"}) journal.update_settings(window.vault, git={"token": "glpat-keepme"})
page.refresh() page.refresh()
@ -654,7 +804,7 @@ def test_settings_saves_git_without_wiping_the_token(window):
def test_conflict_block_is_hidden_until_there_is_one(window): def test_conflict_block_is_hidden_until_there_is_one(window):
page = window.pages[6][1] page = page_of(window, "Настройки")
page.refresh() page.refresh()
assert page.conflict_box.isVisibleTo(page) is False assert page.conflict_box.isVisibleTo(page) is False