diff --git a/README.md b/README.md index 80c99ff..9c21dff 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,49 @@ Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период. - **Долги** — кто сколько должен, с частичными оплатами. - **Товары** — номенклатура с фасовками и историей изменения цен. +- **Статистика** — периоды, недели, месяцы, годы, разрезы по товарам и людям. - **Журнал** — что менялось в базе, когда и с какой машины. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. +### Статистика + +Шесть разрезов, у каждого график и таблица с итоговой строкой. + +| Разрез | Что показывает | +|---|---| +| **Периоды** | Каждая закупка — период со своим сроком. Долг, отдано, собрано, покрытие, прибыль, статус | +| **Недели** | По ISO-неделям, понедельник–воскресенье | +| **Месяцы** | Календарные месяцы | +| **Годы** | Календарные годы | +| **Товары** | Кто кормит, а кто лежит: наценка, наценка %, съедено, остаток | +| **Люди** | Кто сколько взял, занёс, оставил чаевых и остался должен | + +Пустые корзины между непустыми не создаются: если в марте не торговали, +строки за март не будет. Дырка честнее выдуманного нуля. + +**База расчёта.** Смешивать «когда продал» и «когда получил деньги» нельзя — +это разные вопросы, и ответы расходятся, как только появляется хоть один долг. +Поэтому в таблице два основания одновременно: + +- по дате **продажи** — выручка, себестоимость, наценка, съеденное, чаевые. + Это «сколько наторговал за месяц»; +- по дате **платежа** — колонка «Собрано». Это «сколько денег реально пришло», + и долг, возвращённый в сентябре, попадёт в сентябрь, а не в августовскую + продажу; +- по дате **закупки** — «Закуплено», по дате платежа пекарне — «Отдано пекарне». + +У разреза «Периоды» основание третье: там всё привязано к самой партии, потому +что вопрос стоит иначе — «покрыл ли я эту партию к сроку». + +Общее количество нигде не показывается одним числом — только по единицам +(«1,5 л · 4 шт»). Сумма литров с килограммами выглядит как настоящая цифра, +но смысла не имеет. + +График рисуется на QPainter, а не на QtCharts: та тянет за собой десятки +мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Если +корзин больше 24, на графике показываются последние, и об этом написано прямо +над ним — молча показанная часть выглядела бы как всё. + ### Чаевые Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не @@ -180,9 +220,11 @@ pytest -q | `app/storage.py` | загрузка, атомарная запись, миграции схемы, хеш «грязности» | | `app/journal.py` | **единственный путь записи** в базу + аудит-лог | | `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки | +| `app/stats.py` | сводные цифры по периодам, товарам и людям | | `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений | | `app/paths.py` | где лежат данные и почему отдельно от кода | | `app/ui/quick_sales.py` | быстрый ввод продаж за период | +| `app/ui/chart.py` | столбчатый график на QPainter | | `app/ui/` | остальные экраны на PySide6 | Главное архитектурное правило: **ни один экран не меняет документ напрямую**. diff --git a/app/stats.py b/app/stats.py new file mode 100644 index 0000000..10bc15e --- /dev/null +++ b/app/stats.py @@ -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: + """«17–23 августа», «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)) diff --git a/app/ui/chart.py b/app/ui/chart.py new file mode 100644 index 0000000..9cc0c5d --- /dev/null +++ b/app/ui/chart.py @@ -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() diff --git a/app/ui/main_window.py b/app/ui/main_window.py index f05498c..c9e434c 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -33,6 +33,7 @@ from .page_journal import JournalPage from .page_products import ProductsPage from .page_sales import SalesPage from .page_settings import SettingsPage +from .page_stats import StatsPage AUTOSAVE_MS = 2000 CLOSE_SYNC_WAIT_MS = 20_000 @@ -110,6 +111,7 @@ class MainWindow(QMainWindow): ("Продажи", SalesPage(self)), ("Долги", DebtsPage(self)), ("Товары", ProductsPage(self)), + ("Статистика", StatsPage(self)), ("Журнал", JournalPage(self)), ("Настройки", SettingsPage(self)), ] diff --git a/app/ui/page_stats.py b/app/ui/page_stats.py new file mode 100644 index 0000000..65418c3 --- /dev/null +++ b/app/ui/page_stats.py @@ -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() diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..addb132 --- /dev/null +++ b/tests/test_stats.py @@ -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 · 17–23 августа 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) == [] diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 9e5f41c..d767ab2 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -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): titles = [title for title, _ in window.pages] - assert titles == ["Сводка", "Закупки", "Продажи", "Долги", "Товары", "Журнал", "Настройки"] - assert window.stack.count() == 7 + assert titles == [ + "Сводка", "Закупки", "Продажи", "Долги", + "Товары", "Статистика", "Журнал", "Настройки", + ] + assert window.stack.count() == len(titles) 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): - page = window.pages[1][1] + page = page_of(window, "Закупки") page.refresh() assert page.table.rowCount() == 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): - page = window.pages[1][1] + page = page_of(window, "Закупки") page.refresh() page.table.selectRow(0) page._show_details() @@ -156,20 +168,20 @@ def test_batch_details_show_coverage(window): def test_sales_table_lists_every_kind(window): - page = window.pages[2][1] + page = page_of(window, "Продажи") page.refresh() assert page.table.rowCount() == 3 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.refresh() assert page.table.rowCount() == 1 def test_debts_tree_groups_by_person(window): - page = window.pages[3][1] + page = page_of(window, "Долги") page.refresh() 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): - page = window.pages[4][1] + page = page_of(window, "Товары") page.refresh() names = [page.table.item(r, 0).text() for r in range(page.table.rowCount())] 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): - page = window.pages[4][1] + page = page_of(window, "Товары") journal.update_product(window.vault, buns["мак"].id, archived=True) window.changed() @@ -209,7 +221,7 @@ def test_archived_products_are_hidden_by_default(window, buns): def test_dashboard_renders_metrics_and_deadline(window): from PySide6.QtWidgets import QLabel, QProgressBar - page = window.pages[0][1] + page = page_of(window, "Сводка") page.refresh() 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): - page = window.pages[0][1] + page = page_of(window, "Сводка") journal.create_sale( window.vault, window.report.today, KIND_RETAIL, [{"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): - page = window.pages[5][1] + page = page_of(window, "Журнал") page.refresh() assert page.table.rowCount() == len(window.vault.doc.journal) 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) window.changed() @@ -258,7 +270,7 @@ def test_journal_search_filters(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) window.changed() @@ -268,7 +280,7 @@ def test_journal_search_matches_what_is_displayed(window, buns): def test_journal_entity_filter(window): - page = window.pages[5][1] + page = page_of(window, "Журнал") page.entity.setCurrentIndex(page.entity.findData("batch")) page.refresh() assert page.table.rowCount() == 1 @@ -449,6 +461,144 @@ def test_lines_editor_add_and_remove(window, buns): 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) window.changed() - page = window.pages[2][1] + page = page_of(window, "Продажи") page.refresh() tips = [page.table.item(r, 6).text() for r in range(page.table.rowCount())] 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): from PySide6.QtWidgets import QLabel - page = window.pages[0][1] + page = page_of(window, "Сводка") page.refresh() texts = " ".join(label.text() for label in page.body.findChildren(QLabel)) assert "чаевые" not in texts.lower() @@ -623,7 +773,7 @@ def test_fractional_quantity_is_displayed_without_trailing_zeros(window): ) window.changed() - page = window.pages[2][1] + page = page_of(window, "Продажи") page.refresh() texts = [page.table.item(r, 3).text() for r in range(page.table.rowCount())] 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): - page = window.pages[6][1] + page = page_of(window, "Настройки") page.refresh() assert "товаров 3" 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): """Пустое поле токена означает «не трогать», а не «стереть».""" - page = window.pages[6][1] + page = page_of(window, "Настройки") journal.update_settings(window.vault, git={"token": "glpat-keepme"}) 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): - page = window.pages[6][1] + page = page_of(window, "Настройки") page.refresh() assert page.conflict_box.isVisibleTo(page) is False