diff --git a/README.md b/README.md index e9d7bd9..773426e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ - **Закупки** — партии с составом, ценами и датой, до которой надо рассчитаться. - **Продажи** — розница, «другу по себестоимости», «съел сам», подарок, списание. - Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период. + Цена подставляется по типу. Есть быстрый ввод пачки продаж за прошедший период, + фильтры и сортировка. - **Долги** — кто сколько должен, с частичными оплатами. - **Товары** — номенклатура с фасовками и историей изменения цен. - **Статистика** — периоды, недели, месяцы, годы, разрезы по товарам и людям. @@ -73,6 +74,39 @@ корзин больше 24, на графике показываются последние, и об этом написано прямо над ним — молча показанная часть выглядела бы как всё. +### Отбор продаж + +Список продаж растёт быстрее всех остальных, поэтому у него своя панель +отбора: поиск, период, тип, человек и состояние оплаты. Всё складывается — +«за прошлый месяц Васе в долг» набирается тремя щелчками. + +| Фильтр | Что умеет | +|---|---| +| **Поиск** | По товару, имени, заметке и названию типа | +| **Период** | Сегодня, неделя, месяц, прошлый месяц, 30 дней, год, свой диапазон | +| **Тип** | Розница, другу, съел сам, подарок, списание | +| **Кому** | Конкретный человек либо «без контрагента» | +| **Оплата** | С долгом, без долга, с чаевыми | + +Слова в поиске ищутся по отдельности: «вася мак» находит булки с маком, +проданные Васе, хотя подряд эти слова нигде не написаны. + +Поля своего диапазона появляются, только когда он выбран, — иначе две даты +занимали бы место в панели всё остальное время. Кнопка «Сбросить» гаснет, +когда сбрасывать нечего. + +Под таблицей — итоги **ровно по показанному**: сколько продаж, на какую сумму, +сколько оплачено, чаевых и долга. Половина смысла фильтров в этой строке: +отобрал по человеку — сразу видно, сколько он взял и сколько за ним осталось. + +Сортировка — щелчком по заголовку любой колонки, выбор переживает +перерисовку списка. По умолчанию сверху свежие. + +Даты сортируются по времени, а не по тексту. Отформатированная дата — обычная +строка, и по алфавиту она идёт по дню месяца: 01.12.2025 оказывается раньше +02.01.2020. Сортировка при этом выглядит рабочей, просто выдаёт бессмыслицу, +поэтому в ячейке рядом с текстом лежит настоящее значение. + ### Пекарня простила остаток Бывает, что пекарня забирает меньше, чем причиталось: надо было отдать 5 500, @@ -269,6 +303,7 @@ pytest -q | `app/journal.py` | **единственный путь записи** в базу + аудит-лог | | `app/ledger.py` | FIFO в базовых единицах, покрытие партий, долги, остатки | | `app/stats.py` | сводные цифры по периодам, товарам и людям | +| `app/salesfilter.py` | правила отбора продаж: период, поиск, оплата | | `app/gitsync.py` | git, схлопывание коммитов, разрешение расхождений | | `app/paths.py` | где лежат данные и почему отдельно от кода | | `app/ui/quick_sales.py` | быстрый ввод продаж за период | diff --git a/app/salesfilter.py b/app/salesfilter.py new file mode 100644 index 0000000..0ace783 --- /dev/null +++ b/app/salesfilter.py @@ -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) diff --git a/app/ui/page_batches.py b/app/ui/page_batches.py index abb421f..da47d87 100644 --- a/app/ui/page_batches.py +++ b/app/ui/page_batches.py @@ -249,8 +249,8 @@ class BatchesPage(QWidget): rows.append( [ w.sortable_num_item(str(batch.number), batch.number), - w.text_item(batch.date.strftime("%d.%m.%Y")), - w.text_item(batch.due_date.strftime("%d.%m.%Y")), + w.date_item(batch.date), + w.date_item(batch.due_date), 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.paid_to_bakery, currency), report.paid_to_bakery), diff --git a/app/ui/page_sales.py b/app/ui/page_sales.py index 7f93c6e..339eada 100644 --- a/app/ui/page_sales.py +++ b/app/ui/page_sales.py @@ -2,6 +2,7 @@ from __future__ import annotations +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QDialog, @@ -14,6 +15,7 @@ from PySide6.QtWidgets import ( from .. import journal from .. import money as m +from .. import salesfilter as sf from ..models import ( CONSUMPTION_KINDS, KIND_FRIEND, @@ -213,6 +215,8 @@ class SaleDialog(QDialog): 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): super().__init__(parent) self.ctx = ctx @@ -222,13 +226,6 @@ class SalesPage(QWidget): layout.setSpacing(12) 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( w.row( w.button("+ Продажа", "primary", self.create), @@ -237,29 +234,137 @@ class SalesPage(QWidget): w.button("Принять оплату", on_click=self.take_payment), w.button("Удалить", "danger", self.delete), None, - self.filter, ) ) + layout.addWidget(self._filter_bar()) self.table = w.table( ["Дата", "Тип", "Кому", "Что", "Сумма", "Оплачено", "Чаевые", "Долг"], stretch_column=3, ) self.table.doubleClicked.connect(self.edit) + # Свежие продажи сверху. Индикатор в заголовке при этом соответствует + # тому, что видно: без него сортировка «по умолчанию» ничем не помечена, + # и первый клик по «Дате» кажется ничего не делающим. + self.table.sortByColumn(self.COL_DATE, Qt.DescendingOrder) 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: doc = self.ctx.vault.doc 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 = [], [] - for sale in sorted(doc.sales, key=lambda s: s.date, reverse=True): - if wanted and sale.kind != wanted: - continue - + for sale in shown: what = ", ".join( f"{doc.product_name(line.product_id)} × {m.fmt_qty(line.qty)} " f"{line.unit_name(doc.product(line.product_id))}" @@ -268,7 +373,7 @@ class SalesPage(QWidget): debt = sale.debt rows.append( [ - w.text_item(sale.date.strftime("%d.%m.%Y")), + w.date_item(sale.date), w.text_item( SALE_KIND_LABELS.get(sale.kind, sale.kind), theme.MUTED if sale.is_consumption else "", @@ -292,6 +397,10 @@ class SalesPage(QWidget): keys.append(sale.id) 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) # --- действия --- diff --git a/app/ui/widgets.py b/app/ui/widgets.py index 723b81f..6c4fa4e 100644 --- a/app/ui/widgets.py +++ b/app/ui/widgets.py @@ -298,6 +298,25 @@ def sortable_num_item(text: str, value, color: str = "", bold: bool = False) -> 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: """Заполнить таблицу, сохранив идентификатор строки в UserRole нулевой ячейки. diff --git a/tests/test_salesfilter.py b/tests/test_salesfilter.py new file mode 100644 index 0000000..3b8b531 --- /dev/null +++ b/tests/test_salesfilter.py @@ -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 diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 65daf71..9513feb 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -175,10 +175,99 @@ def test_sales_table_lists_every_kind(window): def test_sales_filter_narrows_the_list(window): page = page_of(window, "Продажи") - page.filter.setCurrentIndex(page.filter.findData(KIND_SELF)) - page.refresh() + page.kind.setCurrentIndex(page.kind.findData(KIND_SELF)) 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): page = page_of(window, "Долги")