Фильтры и сортировка на экране продаж
Список продаж растёт быстрее всех остальных, а отбор был один — по типу. Теперь панель: поиск, период, тип, человек, состояние оплаты. Всё складывается, «за прошлый месяц Васе в долг» набирается тремя щелчками. Правила отбора вынесены в app/salesfilter.py и живут без Qt: «прошлый месяц» в январе уезжает в прошлый год, а поиск по двум словам ищет их по отдельности — такое проверяется тестом, а не глазами по скриншоту. Под таблицей — итоги ровно по показанному. Половина смысла фильтров в этой строке: отобрал по человеку и сразу видишь, сколько он взял и сколько за ним осталось. Заодно починена сортировка по датам на продажах и закупках. Отформатированная дата — обычная строка, и по алфавиту она сортируется по дню месяца: 01.12.2025 оказывалась раньше 02.01.2020. Выглядело рабочим, просто выдавало бессмыслицу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
950ccc33b3
commit
e2795d92f8
37
README.md
37
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` | быстрый ввод продаж за период |
|
||||
|
||||
245
app/salesfilter.py
Normal file
245
app/salesfilter.py
Normal 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)
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
# --- действия ---
|
||||
|
||||
|
||||
@ -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 нулевой ячейки.
|
||||
|
||||
|
||||
202
tests/test_salesfilter.py
Normal file
202
tests/test_salesfilter.py
Normal 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
|
||||
@ -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, "Долги")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user