Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы, годы, товары и люди. У каждого график и таблица с итоговой строкой. Главное решение — две базы расчёта одновременно. Смешивать «когда продал» и «когда получил деньги» нельзя: ответы расходятся, как только появляется хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по своим датам. У разреза «Периоды» основание третье, привязка к партии, потому что вопрос там другой: покрыл ли я её к сроку. Количество нигде не сводится в одно число — только по единицам, «1,5 л · 4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям. График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24 корзин на график не помещается, и про срез написано над ним — молча показанная часть выглядела бы как всё. Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел ломал половину проверок. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
582f041dba
commit
38e9614a25
42
README.md
42
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 |
|
||||
|
||||
Главное архитектурное правило: **ни один экран не меняет документ напрямую**.
|
||||
|
||||
439
app/stats.py
Normal file
439
app/stats.py
Normal 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:
|
||||
"""«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))
|
||||
136
app/ui/chart.py
Normal file
136
app/ui/chart.py
Normal 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()
|
||||
@ -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)),
|
||||
]
|
||||
|
||||
408
app/ui/page_stats.py
Normal file
408
app/ui/page_stats.py
Normal 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
438
tests/test_stats.py
Normal 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 · 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) == []
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user