Фильтры и сортировка на экране продаж
Список продаж растёт быстрее всех остальных, а отбор был один — по типу. Теперь панель: поиск, период, тип, человек, состояние оплаты. Всё складывается, «за прошлый месяц Васе в долг» набирается тремя щелчками. Правила отбора вынесены в 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, на графике показываются последние, и об этом написано прямо
|
корзин больше 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
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(
|
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),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
# --- действия ---
|
# --- действия ---
|
||||||
|
|
||||||
|
|||||||
@ -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
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):
|
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, "Долги")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user