Фильтры и сортировка на экране продаж

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

Правила отбора вынесены в app/salesfilter.py и живут без Qt: «прошлый
месяц» в январе уезжает в прошлый год, а поиск по двум словам ищет их по
отдельности — такое проверяется тестом, а не глазами по скриншоту.

Под таблицей — итоги ровно по показанному. Половина смысла фильтров
в этой строке: отобрал по человеку и сразу видишь, сколько он взял
и сколько за ним осталось.

Заодно починена сортировка по датам на продажах и закупках.
Отформатированная дата — обычная строка, и по алфавиту она сортируется
по дню месяца: 01.12.2025 оказывалась раньше 02.01.2020. Выглядело
рабочим, просто выдавало бессмыслицу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
food-market 2026-08-24 18:58:52 +03:00
parent 950ccc33b3
commit e2795d92f8
7 changed files with 718 additions and 19 deletions

View File

@ -26,7 +26,8 @@
- **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться. - **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться.
- **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание. - **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание.
Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период. Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период,
фильтры и сортировка.
- **Долги** — кто сколько должен, с частичными оплатами. - **Долги** — кто сколько должен, с частичными оплатами.
- **Товары** — номенклатура с фасовками и историей изменения цен. - **Товары** — номенклатура с фасовками и историей изменения цен.
- **Статистика** — периоды, недели, месяцы, годы, разрезы по товарам и людям. - **Статистика** — периоды, недели, месяцы, годы, разрезы по товарам и людям.
@ -73,6 +74,39 @@
корзин больше 24, на графике показываются последние, и об этом написано прямо корзин больше 24, на графике показываются последние, и об этом написано прямо
над ним — молча показанная часть выглядела бы как всё. над ним — молча показанная часть выглядела бы как всё.
### Отбор продаж
Список продаж растёт быстрее всех остальных, поэтому у него своя панель
отбора: поиск, период, тип, человек и состояние оплаты. Всё складывается —
«за прошлый месяц Васе в долг» набирается тремя щелчками.
| Фильтр | Что умеет |
|---|---|
| **Поиск** | По товару, имени, заметке и названию типа |
| **Период** | Сегодня, неделя, месяц, прошлый месяц, 30 дней, год, свой диапазон |
| **Тип** | Розница, другу, съел сам, подарок, списание |
| **Кому** | Конкретный человек либо «без контрагента» |
| **Оплата** | С долгом, без долга, с чаевыми |
Слова в поиске ищутся по отдельности: «вася мак» находит булки с маком,
проданные Васе, хотя подряд эти слова нигде не написаны.
Поля своего диапазона появляются, только когда он выбран, — иначе две даты
занимали бы место в панели всё остальное время. Кнопка «Сбросить» гаснет,
когда сбрасывать нечего.
Под таблицей — итоги **ровно по показанному**: сколько продаж, на какую сумму,
сколько оплачено, чаевых и долга. Половина смысла фильтров в этой строке:
отобрал по человеку — сразу видно, сколько он взял и сколько за ним осталось.
Сортировка — щелчком по заголовку любой колонки, выбор переживает
перерисовку списка. По умолчанию сверху свежие.
Даты сортируются по времени, а не по тексту. Отформатированная дата — обычная
строка, и по алфавиту она идёт по дню месяца: 01.12.2025 оказывается раньше
02.01.2020. Сортировка при этом выглядит рабочей, просто выдаёт бессмыслицу,
поэтому в ячейке рядом с текстом лежит настоящее значение.
### Пекарня простила остаток ### Пекарня простила остаток
Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500, Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500,
@ -269,6 +303,7 @@ pytest -q
| `app/journal.py` | **единственный путь записи** в базу + аудит-лог | | `app/journal.py` | **единственный путь записи** в базу + аудит-лог |
| `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки | | `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки |
| `app/stats.py` | сводные цифры по периодам, товарам и людям | | `app/stats.py` | сводные цифры по периодам, товарам и людям |
| `app/salesfilter.py` | правила отбора продаж: период, поиск, оплата |
| `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений | | `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений |
| `app/paths.py` | где лежат данные и почему отдельно от кода | | `app/paths.py` | где лежат данные и почему отдельно от кода |
| `app/ui/quick_sales.py` | быстрый ввод продаж за период | | `app/ui/quick_sales.py` | быстрый ввод продаж за период |

245
app/salesfilter.py Normal file
View File

@ -0,0 +1,245 @@
"""Отбор продаж для экрана: период, тип, контрагент, деньги, поиск.
Живёт отдельно от Qt намеренно. Отбор это правила, а не виджеты: «за прошлый
месяц Васе в долг» должно проверяться тестом, а не глазами по скриншоту.
Экран отвечает только за то, чтобы собрать сюда состояние полей и показать
результат.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from . import money as m
from .models import SALE_KIND_LABELS, Document, Sale
from .stats import month_bounds, week_bounds, year_bounds
# --- периоды --------------------------------------------------------------
PERIOD_ALL = "all"
PERIOD_TODAY = "today"
PERIOD_WEEK = "week"
PERIOD_MONTH = "month"
PERIOD_PREV_MONTH = "prev_month"
PERIOD_LAST_30 = "last30"
PERIOD_YEAR = "year"
PERIOD_CUSTOM = "custom"
# Порядок — он же порядок в выпадающем списке: от общего к частному,
# самое ходовое сверху.
PERIODS = (
PERIOD_ALL,
PERIOD_TODAY,
PERIOD_WEEK,
PERIOD_MONTH,
PERIOD_PREV_MONTH,
PERIOD_LAST_30,
PERIOD_YEAR,
PERIOD_CUSTOM,
)
PERIOD_LABELS = {
PERIOD_ALL: "Всё время",
PERIOD_TODAY: "Сегодня",
PERIOD_WEEK: "Эта неделя",
PERIOD_MONTH: "Этот месяц",
PERIOD_PREV_MONTH: "Прошлый месяц",
PERIOD_LAST_30: "Последние 30 дней",
PERIOD_YEAR: "Этот год",
PERIOD_CUSTOM: "Свой диапазон",
}
# --- состояние оплаты -----------------------------------------------------
MONEY_ANY = ""
MONEY_DEBT = "debt"
MONEY_CLEAR = "clear"
MONEY_TIP = "tip"
MONEY_STATES = (MONEY_ANY, MONEY_DEBT, MONEY_CLEAR, MONEY_TIP)
MONEY_LABELS = {
MONEY_ANY: "Любая оплата",
MONEY_DEBT: "С долгом",
MONEY_CLEAR: "Без долга",
MONEY_TIP: "С чаевыми",
}
# Отдельное значение для «продажа без контрагента»: None в этом поле уже занят
# смыслом «любой контрагент», и без сентинела эти два случая не различить.
NO_COUNTERPARTY = "__none__"
@dataclass(frozen=True)
class SalesFilter:
"""Что именно показывать. Пустые поля ничего не отсекают."""
text: str = ""
kind: str | None = None
counterparty_id: str | None = None
period: str = PERIOD_ALL
since: date | None = None
until: date | None = None
money: str = MONEY_ANY
@property
def is_empty(self) -> bool:
return (
not self.text.strip()
and not self.kind
and not self.counterparty_id
and self.period == PERIOD_ALL
and not self.money
)
def period_bounds(period: str, today: date, since=None, until=None):
"""Границы периода включительно. None с любой стороны — без ограничения."""
if period == PERIOD_TODAY:
return today, today
if period == PERIOD_WEEK:
return week_bounds(today)
if period == PERIOD_MONTH:
return month_bounds(today)
if period == PERIOD_PREV_MONTH:
start, _ = month_bounds(today)
return month_bounds(start - timedelta(days=1))
if period == PERIOD_LAST_30:
# Включая сегодня, поэтому 29, а не 30: иначе в «последние 30 дней»
# попадал бы тридцать один день.
return today - timedelta(days=29), today
if period == PERIOD_YEAR:
return year_bounds(today)
if period == PERIOD_CUSTOM:
return since, until
return None, None
# --- отбор ----------------------------------------------------------------
def haystack(doc: Document, sale: Sale) -> str:
"""Всё, по чему ищется продажа, одной строкой в нижнем регистре."""
parts = [
SALE_KIND_LABELS.get(sale.kind, sale.kind),
doc.counterparty_name(sale.counterparty_id),
sale.note,
]
parts.extend(doc.product_name(line.product_id) for line in sale.lines)
return " ".join(parts).lower()
def _matches_text(doc: Document, sale: Sale, query: str) -> bool:
"""Все слова запроса должны найтись — но каждое где угодно.
«вася мак» находит продажу булок с маком Васе, хотя подряд эти слова
нигде не стоят.
"""
words = query.lower().split()
if not words:
return True
text = haystack(doc, sale)
return all(word in text for word in words)
def _matches_money(sale: Sale, state: str) -> bool:
if state == MONEY_DEBT:
return sale.debt > 0
if state == MONEY_CLEAR:
return sale.debt <= 0
if state == MONEY_TIP:
return sale.tip > 0
return True
def apply(doc: Document, sales, f: SalesFilter, today: date | None = None) -> list[Sale]:
since, until = period_bounds(f.period, today or date.today(), f.since, f.until)
result = []
for sale in sales:
if since is not None and sale.date < since:
continue
if until is not None and sale.date > until:
continue
if f.kind and sale.kind != f.kind:
continue
if f.counterparty_id == NO_COUNTERPARTY:
if sale.counterparty_id:
continue
elif f.counterparty_id and sale.counterparty_id != f.counterparty_id:
continue
if not _matches_money(sale, f.money):
continue
if not _matches_text(doc, sale, f.text):
continue
result.append(sale)
return result
# --- итоги по отобранному -------------------------------------------------
@dataclass
class SalesTotals:
count: int = 0
total: Decimal = m.ZERO
paid: Decimal = m.ZERO
tip: Decimal = m.ZERO
debt: Decimal = m.ZERO
def totals(sales) -> SalesTotals:
"""Итоги ровно по тому, что видно на экране.
Смысл фильтров наполовину в этой строке: отобрал по человеку сразу
видно, сколько он взял и сколько за ним осталось.
"""
result = SalesTotals()
for sale in sales:
result.count += 1
result.total += sale.total
result.paid += sale.paid
result.tip += sale.tip
result.debt += sale.debt
result.total = m.money(result.total)
result.paid = m.money(result.paid)
result.tip = m.money(result.tip)
result.debt = m.money(result.debt)
return result
def plural(count: int, one: str, few: str, many: str) -> str:
"""Русское склонение после числа: 1 продажа, 2 продажи, 5 продаж."""
if count % 100 in range(11, 15):
return many
last = count % 10
if last == 1:
return one
if last in (2, 3, 4):
return few
return many
def summary_text(shown: SalesTotals, total_count: int, currency: str) -> str:
"""Строка под таблицей: сколько показано и на сколько денег."""
if not shown.count:
return "Ничего не найдено — попробуй ослабить фильтры."
word = plural(shown.count, "продажа", "продажи", "продаж")
head = (
f"{shown.count} {word}"
if shown.count == total_count
else f"Показано {shown.count} из {total_count}"
)
parts = [head, f"сумма {m.fmt_money(shown.total, currency)}"]
if shown.paid != shown.total:
parts.append(f"оплачено {m.fmt_money(shown.paid, currency)}")
if shown.tip > 0:
parts.append(f"чаевые {m.fmt_money(shown.tip, currency)}")
if shown.debt > 0:
parts.append(f"долг {m.fmt_money(shown.debt, currency)}")
return " · ".join(parts)

View File

@ -249,8 +249,8 @@ class BatchesPage(QWidget):
rows.append( rows.append(
[ [
w.sortable_num_item(str(batch.number), batch.number), w.sortable_num_item(str(batch.number), batch.number),
w.text_item(batch.date.strftime("%d.%m.%Y")), w.date_item(batch.date),
w.text_item(batch.due_date.strftime("%d.%m.%Y")), w.date_item(batch.due_date),
w.text_item(days, color), w.text_item(days, color),
w.sortable_num_item(m.fmt_money(report.cost_total, currency), report.cost_total), w.sortable_num_item(m.fmt_money(report.cost_total, currency), report.cost_total),
w.sortable_num_item(m.fmt_money(report.paid_to_bakery, currency), report.paid_to_bakery), w.sortable_num_item(m.fmt_money(report.paid_to_bakery, currency), report.paid_to_bakery),

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
@ -14,6 +15,7 @@ from PySide6.QtWidgets import (
from .. import journal from .. import journal
from .. import money as m from .. import money as m
from .. import salesfilter as sf
from ..models import ( from ..models import (
CONSUMPTION_KINDS, CONSUMPTION_KINDS,
KIND_FRIEND, KIND_FRIEND,
@ -213,6 +215,8 @@ class SaleDialog(QDialog):
class SalesPage(QWidget): class SalesPage(QWidget):
COL_DATE, COL_KIND, COL_WHO, COL_WHAT, COL_TOTAL, COL_PAID, COL_TIP, COL_DEBT = range(8)
def __init__(self, ctx, parent=None): def __init__(self, ctx, parent=None):
super().__init__(parent) super().__init__(parent)
self.ctx = ctx self.ctx = ctx
@ -222,13 +226,6 @@ class SalesPage(QWidget):
layout.setSpacing(12) layout.setSpacing(12)
layout.addWidget(w.heading("Продажи")) layout.addWidget(w.heading("Продажи"))
self.filter = QComboBox()
self.filter.addItem("Все типы", None)
for kind in SALE_KINDS:
self.filter.addItem(SALE_KIND_LABELS[kind], kind)
self.filter.currentIndexChanged.connect(self.refresh)
layout.addWidget( layout.addWidget(
w.row( w.row(
w.button("+ Продажа", "primary", self.create), w.button("+ Продажа", "primary", self.create),
@ -237,29 +234,137 @@ class SalesPage(QWidget):
w.button("Принять оплату", on_click=self.take_payment), w.button("Принять оплату", on_click=self.take_payment),
w.button("Удалить", "danger", self.delete), w.button("Удалить", "danger", self.delete),
None, None,
self.filter,
) )
) )
layout.addWidget(self._filter_bar())
self.table = w.table( self.table = w.table(
["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"], ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"],
stretch_column=3, stretch_column=3,
) )
self.table.doubleClicked.connect(self.edit) self.table.doubleClicked.connect(self.edit)
# Свежие продажи сверху. Индикатор в заголовке при этом соответствует
# тому, что видно: без него сортировка «по умолчанию» ничем не помечена,
# и первый клик по «Дате» кажется ничего не делающим.
self.table.sortByColumn(self.COL_DATE, Qt.DescendingOrder)
layout.addWidget(self.table, 1) layout.addWidget(self.table, 1)
self.summary = w.label("", "dim")
layout.addWidget(self.summary)
# --- фильтры ---
def _filter_bar(self) -> QWidget:
"""Панель отбора. Всё, что её меняет, ведёт в один refresh."""
self.search = QLineEdit()
self.search.setPlaceholderText("Поиск: товар, имя, заметка")
self.search.setClearButtonEnabled(True)
self.search.textChanged.connect(self.refresh)
self.period = QComboBox()
for key in sf.PERIODS:
self.period.addItem(sf.PERIOD_LABELS[key], key)
self.period.currentIndexChanged.connect(self._on_period_changed)
# Свои даты нужны редко, а места занимают много — показываем их только
# когда выбран собственный диапазон.
self.since = w.DateInput()
self.until = w.DateInput()
for field in (self.since, self.until):
field.dateChanged.connect(lambda _: self.refresh())
field.setVisible(False)
self.kind = QComboBox()
self.kind.addItem("Все типы", None)
for kind in SALE_KINDS:
self.kind.addItem(SALE_KIND_LABELS[kind], kind)
self.kind.currentIndexChanged.connect(self.refresh)
self.who = QComboBox()
self.who.currentIndexChanged.connect(self.refresh)
self._fill_counterparties()
self.money = QComboBox()
for state in sf.MONEY_STATES:
self.money.addItem(sf.MONEY_LABELS[state], state)
self.money.currentIndexChanged.connect(self.refresh)
self.reset_button = w.button("Сбросить", on_click=self.reset_filters)
return w.row(
self.search,
self.period,
self.since,
self.until,
self.kind,
self.who,
self.money,
self.reset_button,
stretch_at=0,
)
def _fill_counterparties(self) -> None:
"""Пересобрать список людей, сохранив выбранного.
Контрагенты заводятся прямо из формы продажи, поэтому список живой.
Восстанавливаем по идентификатору: под тем же номером после сортировки
по алфавиту оказывается уже другой человек.
"""
previously = self.who.currentData()
self.who.blockSignals(True)
self.who.clear()
self.who.addItem("Все люди", None)
self.who.addItem("Без контрагента", sf.NO_COUNTERPARTY)
for cp in sorted(self.ctx.vault.doc.counterparties, key=lambda c: c.name.lower()):
self.who.addItem(cp.name, cp.id)
index = self.who.findData(previously) if previously else 0
self.who.setCurrentIndex(max(0, index))
self.who.blockSignals(False)
def _on_period_changed(self) -> None:
custom = self.period.currentData() == sf.PERIOD_CUSTOM
self.since.setVisible(custom)
self.until.setVisible(custom)
self.refresh()
def current_filter(self) -> sf.SalesFilter:
return sf.SalesFilter(
text=self.search.text(),
kind=self.kind.currentData(),
counterparty_id=self.who.currentData(),
period=self.period.currentData() or sf.PERIOD_ALL,
since=self.since.get_date(),
until=self.until.get_date(),
money=self.money.currentData() or sf.MONEY_ANY,
)
def reset_filters(self) -> None:
for widget in (self.period, self.kind, self.who, self.money):
widget.blockSignals(True)
widget.setCurrentIndex(0)
widget.blockSignals(False)
self.search.blockSignals(True)
self.search.clear()
self.search.blockSignals(False)
self._on_period_changed()
# --- отрисовка --- # --- отрисовка ---
def refresh(self) -> None: def refresh(self) -> None:
doc = self.ctx.vault.doc doc = self.ctx.vault.doc
currency = self.ctx.currency currency = self.ctx.currency
wanted = self.filter.currentData()
self._fill_counterparties()
active = self.current_filter()
shown = sf.apply(
doc,
sorted(doc.sales, key=lambda s: s.date, reverse=True),
active,
self.ctx.report.today,
)
rows, keys = [], [] rows, keys = [], []
for sale in sorted(doc.sales, key=lambda s: s.date, reverse=True): for sale in shown:
if wanted and sale.kind != wanted:
continue
what = ", ".join( what = ", ".join(
f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} " f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} "
f"{line.unit_name(doc.product(line.product_id))}" f"{line.unit_name(doc.product(line.product_id))}"
@ -268,7 +373,7 @@ class SalesPage(QWidget):
debt = sale.debt debt = sale.debt
rows.append( rows.append(
[ [
w.text_item(sale.date.strftime("%d.%m.%Y")), w.date_item(sale.date),
w.text_item( w.text_item(
SALE_KIND_LABELS.get(sale.kind, sale.kind), SALE_KIND_LABELS.get(sale.kind, sale.kind),
theme.MUTED if sale.is_consumption else "", theme.MUTED if sale.is_consumption else "",
@ -292,6 +397,10 @@ class SalesPage(QWidget):
keys.append(sale.id) keys.append(sale.id)
w.fill(self.table, rows, keys) w.fill(self.table, rows, keys)
self.summary.setText(
sf.summary_text(sf.totals(shown), len(doc.sales), currency)
)
self.reset_button.setEnabled(not active.is_empty)
# --- действия --- # --- действия ---

View File

@ -298,6 +298,25 @@ def sortable_num_item(text: str, value, color: str = "", bold: bool = False) ->
return item return item
def date_item(value: date, color: str = "", bold: bool = False) -> SortableItem:
"""Ячейка с датой: показывается по-русски, сортируется по времени.
Отформатированная дата обычная строка, и по алфавиту она сортируется по
дню месяца: 01.12.2025 оказывается раньше 02.01.2020. Сортировка по такой
колонке выглядит рабочей, просто выдаёт бессмыслицу поэтому настоящее
значение кладём рядом с текстом.
"""
item = SortableItem(value.strftime("%d.%m.%Y"))
if color:
item.setForeground(QColor(color))
if bold:
font = item.font()
font.setBold(True)
item.setFont(font)
item.setData(Qt.UserRole + 1, float(value.toordinal()))
return item
def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None: def fill(widget: QTableWidget, rows: list[list[QTableWidgetItem]], keys: list = None) -> None:
"""Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки. """Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки.

202
tests/test_salesfilter.py Normal file
View File

@ -0,0 +1,202 @@
"""Отбор продаж: правила, а не виджеты.
Фильтры на экране легко «проверить глазами» и не заметить, что «прошлый месяц»
в январе уезжает в прошлый год, а поиск по двум словам ничего не находит.
Поэтому правила живут отдельно от Qt и проверяются здесь.
"""
from datetime import date, timedelta
from decimal import Decimal
import pytest
from app import journal
from app import salesfilter as sf
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF
@pytest.fixture
def sales(vault, buns, today, in_two_weeks):
"""Партия и четыре разные продажи: розница, друг в долг, съеденное, чаевые."""
journal.create_batch(
vault,
today - timedelta(days=60),
in_two_weeks,
[
{"product_id": buns["повидло"].id, "qty": 50, "unit_cost": 30},
{"product_id": buns["мак"].id, "qty": 50, "unit_cost": 20},
],
)
vasya = journal.create_counterparty(vault, "Вася")
petya = journal.create_counterparty(vault, "Петя")
journal.create_sale(
vault, today, KIND_RETAIL,
[{"product_id": buns["повидло"].id, "qty": 2, "unit_price": 50}],
note="через дорогу",
)
journal.create_sale(
vault, today - timedelta(days=40), KIND_FRIEND,
[{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}],
counterparty_id=vasya.id, paid_amount=0,
)
journal.create_sale(
vault, today - timedelta(days=2), KIND_RETAIL,
[{"product_id": buns["мак"].id, "qty": 1, "unit_price": 35}],
counterparty_id=petya.id, paid_amount=50,
)
journal.create_sale(
vault, today, KIND_SELF,
[{"product_id": buns["повидло"].id, "qty": 1, "unit_price": 0}],
)
vault.doc.vasya = vasya
vault.doc.petya = petya
return vault.doc
def run(doc, today, **kwargs):
return sf.apply(doc, doc.sales, sf.SalesFilter(**kwargs), today)
# --- периоды --------------------------------------------------------------
def test_empty_filter_shows_everything(sales, today):
assert len(run(sales, today)) == 4
def test_today_keeps_only_todays_sales(sales, today):
found = run(sales, today, period=sf.PERIOD_TODAY)
assert {s.date for s in found} == {today}
assert len(found) == 2
def test_last_30_days_includes_today(sales, today):
since, until = sf.period_bounds(sf.PERIOD_LAST_30, today)
assert until == today
# Тридцать дней, а не тридцать один: сегодняшний день тоже считается.
assert (until - since).days == 29
found = run(sales, today, period=sf.PERIOD_LAST_30)
assert len(found) == 3 # без продажи 40-дневной давности
def test_previous_month_rolls_over_the_year():
"""В январе прошлый месяц — декабрь прошлого года, а не декабрь этого."""
since, until = sf.period_bounds(sf.PERIOD_PREV_MONTH, date(2026, 1, 15))
assert since == date(2025, 12, 1)
assert until == date(2025, 12, 31)
def test_custom_range_is_inclusive(sales, today):
day = today - timedelta(days=2)
found = run(sales, today, period=sf.PERIOD_CUSTOM, since=day, until=day)
assert [s.date for s in found] == [day]
def test_custom_range_without_dates_shows_everything(sales, today):
assert len(run(sales, today, period=sf.PERIOD_CUSTOM)) == 4
# --- признаки -------------------------------------------------------------
def test_kind_filter(sales, today):
assert len(run(sales, today, kind=KIND_SELF)) == 1
assert len(run(sales, today, kind=KIND_RETAIL)) == 2
def test_counterparty_filter(sales, today):
found = run(sales, today, counterparty_id=sales.vasya.id)
assert [s.kind for s in found] == [KIND_FRIEND]
def test_sales_without_a_counterparty_are_their_own_case(sales, today):
"""«Все люди» и «без контрагента» — разные вещи, и None не различает их."""
found = run(sales, today, counterparty_id=sf.NO_COUNTERPARTY)
assert len(found) == 2
assert all(s.counterparty_id is None for s in found)
def test_debt_and_clear(sales, today):
with_debt = run(sales, today, money=sf.MONEY_DEBT)
assert len(with_debt) == 1
assert with_debt[0].counterparty_id == sales.vasya.id
assert len(run(sales, today, money=sf.MONEY_CLEAR)) == 3
def test_tip_filter(sales, today):
"""Пете дали 50 за булку в 35 — сдачу оставили."""
found = run(sales, today, money=sf.MONEY_TIP)
assert len(found) == 1
assert found[0].tip == Decimal("15.00")
# --- поиск ----------------------------------------------------------------
def test_search_finds_by_product_person_and_note(sales, today):
assert len(run(sales, today, text="мак")) == 2
assert len(run(sales, today, text="Вася")) == 1
assert len(run(sales, today, text="через дорогу")) == 1
assert len(run(sales, today, text="съел")) == 1 # по названию типа
def test_search_words_are_independent(sales, today):
"""«вася мак» — два слова в разных полях, подряд их нигде нет."""
assert len(run(sales, today, text="вася мак")) == 1
assert len(run(sales, today, text="вася повидло")) == 0
def test_search_ignores_case_and_spacing(sales, today):
assert len(run(sales, today, text=" ВАСЯ ")) == 1
# --- сочетания и итоги ----------------------------------------------------
def test_filters_combine(sales, today):
found = run(sales, today, period=sf.PERIOD_LAST_30, kind=KIND_RETAIL, money=sf.MONEY_TIP)
assert len(found) == 1
assert found[0].counterparty_id == sales.petya.id
def test_totals_cover_only_what_is_shown(sales, today):
found = run(sales, today, counterparty_id=sales.vasya.id)
totals = sf.totals(found)
assert totals.count == 1
assert totals.total == Decimal("100.00") # 5 × 20
assert totals.paid == Decimal("0.00")
assert totals.debt == Decimal("100.00")
def test_summary_says_how_much_is_hidden(sales, today):
shown = sf.totals(run(sales, today, kind=KIND_SELF))
text = sf.summary_text(shown, len(sales.sales), "")
assert "Показано 1 из 4" in text
everything = sf.totals(run(sales, today))
assert "4 продажи" in sf.summary_text(everything, len(sales.sales), "")
def test_summary_admits_when_nothing_matches():
assert "Ничего не найдено" in sf.summary_text(sf.SalesTotals(), 10, "")
@pytest.mark.parametrize(
"count,expected",
[(1, "продажа"), (2, "продажи"), (5, "продаж"), (11, "продаж"),
(21, "продажа"), (112, "продаж"), (104, "продажи")],
)
def test_plural_forms(count, expected):
assert sf.plural(count, "продажа", "продажи", "продаж") == expected
def test_is_empty_knows_when_nothing_is_filtered():
assert sf.SalesFilter().is_empty is True
# Свои даты сами по себе ничего не отсекают, пока не выбран диапазон.
assert sf.SalesFilter(since=date(2026, 1, 1)).is_empty is True
assert sf.SalesFilter(text="вася").is_empty is False
assert sf.SalesFilter(period=sf.PERIOD_TODAY).is_empty is False

View File

@ -175,10 +175,99 @@ def test_sales_table_lists_every_kind(window):
def test_sales_filter_narrows_the_list(window): def test_sales_filter_narrows_the_list(window):
page = page_of(window, "Продажи") page = page_of(window, "Продажи")
page.filter.setCurrentIndex(page.filter.findData(KIND_SELF)) page.kind.setCurrentIndex(page.kind.findData(KIND_SELF))
page.refresh()
assert page.table.rowCount() == 1 assert page.table.rowCount() == 1
page.reset_filters()
assert page.table.rowCount() == 3
def test_sales_search_matches_product_and_person(window):
page = page_of(window, "Продажи")
page.search.setText("вася")
assert page.table.rowCount() == 1
assert page.table.item(0, page.COL_WHO).text() == "Вася"
# Слова ищутся по отдельности: подряд «вася мак» нигде не написано.
page.search.setText("вася мак")
assert page.table.rowCount() == 1
page.search.setText("вася повидло")
assert page.table.rowCount() == 0
assert "Ничего не найдено" in page.summary.text()
def test_sales_filter_by_debt_and_person(window):
from app import salesfilter as sf
page = page_of(window, "Продажи")
page.money.setCurrentIndex(page.money.findData(sf.MONEY_DEBT))
assert page.table.rowCount() == 1 # только Вася не заплатил
page.reset_filters()
page.who.setCurrentIndex(page.who.findData(sf.NO_COUNTERPARTY))
assert page.table.rowCount() == 2 # розница и съеденное
def test_sales_summary_counts_what_is_shown(window):
page = page_of(window, "Продажи")
assert "3 продажи" in page.summary.text()
page.who.setCurrentIndex(page.who.findData(window.vasya.id))
text = page.summary.text()
assert "Показано 1 из 3" in text
assert "сумма 100,00 ₽" in text # 5 булок с маком по 20
assert "долг 100,00 ₽" in text
def test_custom_range_fields_appear_only_when_needed(window):
from app import salesfilter as sf
page = page_of(window, "Продажи")
assert page.since.isVisible() is False
page.period.setCurrentIndex(page.period.findData(sf.PERIOD_CUSTOM))
assert page.since.isVisibleTo(page) is True
page.period.setCurrentIndex(page.period.findData(sf.PERIOD_ALL))
assert page.since.isVisibleTo(page) is False
def test_period_filter_hides_older_sales(window):
from app import salesfilter as sf
page = page_of(window, "Продажи")
page.period.setCurrentIndex(page.period.findData(sf.PERIOD_TODAY))
assert page.table.rowCount() == 3 # все три записаны сегодня
page.period.setCurrentIndex(page.period.findData(sf.PERIOD_CUSTOM))
page.since.set_date(date.today() - timedelta(days=30))
page.until.set_date(date.today() - timedelta(days=10))
assert page.table.rowCount() == 0
def test_sales_are_sorted_newest_first(window, buns):
"""Дата в таблице — строка, и по алфавиту она сортируется по дню месяца."""
from PySide6.QtCore import Qt
journal.create_sale(
window.vault,
date.today() - timedelta(days=200),
KIND_RETAIL,
[{"product_id": buns["повидло"].id, "qty": 1, "unit_price": 50}],
)
window.changed()
page = page_of(window, "Продажи")
newest = page.table.item(0, page.COL_DATE).text()
assert newest == date.today().strftime("%d.%m.%Y")
page.table.sortByColumn(page.COL_DATE, Qt.AscendingOrder)
oldest = page.table.item(0, page.COL_DATE).text()
assert oldest == (date.today() - timedelta(days=200)).strftime("%d.%m.%Y")
def test_debts_tree_groups_by_person(window): def test_debts_tree_groups_by_person(window):
page = page_of(window, "Долги") page = page_of(window, "Долги")