2026-08-22 21:29:53 +07:00
|
|
|
|
"""Дымовые тесты интерфейса: окно собирается, экраны рисуются, формы отдают данные.
|
|
|
|
|
|
|
|
|
|
|
|
Гоняются в offscreen-режиме, поэтому идут и в консоли, и без монитора.
|
|
|
|
|
|
Диалоги не показываются через exec() — вместо этого проверяется их содержимое
|
|
|
|
|
|
и то, что из него получается.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
|
|
|
|
|
|
|
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
from app import journal # noqa: E402
|
|
|
|
|
|
from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF # noqa: E402
|
|
|
|
|
|
from app.ui import theme # noqa: E402
|
|
|
|
|
|
from app.ui.main_window import MainWindow # noqa: E402
|
|
|
|
|
|
from app.ui.page_batches import BatchDialog # noqa: E402
|
|
|
|
|
|
from app.ui.page_journal import JournalPage, describe # noqa: E402
|
|
|
|
|
|
from app.ui.page_products import PriceDialog, ProductDialog # noqa: E402
|
|
|
|
|
|
from app.ui.page_sales import SaleDialog, price_source_for # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
|
def qapp():
|
|
|
|
|
|
app = QApplication.instance() or QApplication([])
|
|
|
|
|
|
theme.apply(app)
|
|
|
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def window(qapp, vault, buns, today, in_two_weeks):
|
|
|
|
|
|
"""Окно на живых данных: партия, розница, друг в долг, съеденное."""
|
|
|
|
|
|
batch = journal.create_batch(
|
|
|
|
|
|
vault,
|
|
|
|
|
|
today,
|
|
|
|
|
|
in_two_weeks,
|
|
|
|
|
|
[
|
|
|
|
|
|
{"product_id": buns["повидло"].id, "qty": 5, "unit_cost": 30},
|
|
|
|
|
|
{"product_id": buns["мак"].id, "qty": 10, "unit_cost": 20},
|
|
|
|
|
|
{"product_id": buns["корица"].id, "qty": 20, "unit_cost": 25},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
vasya = journal.create_counterparty(vault, "Вася")
|
|
|
|
|
|
journal.create_sale(
|
|
|
|
|
|
vault, today, KIND_RETAIL, [{"product_id": buns["повидло"].id, "qty": 3, "unit_price": 50}]
|
|
|
|
|
|
)
|
|
|
|
|
|
journal.create_sale(
|
|
|
|
|
|
vault, today, KIND_FRIEND, [{"product_id": buns["мак"].id, "qty": 5, "unit_price": 20}],
|
|
|
|
|
|
counterparty_id=vasya.id, paid_amount=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
journal.create_sale(
|
|
|
|
|
|
vault, today, KIND_SELF, [{"product_id": buns["корица"].id, "qty": 2, "unit_price": 0}]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
win = MainWindow(vault)
|
|
|
|
|
|
win.batch = batch
|
|
|
|
|
|
win.vasya = vasya
|
|
|
|
|
|
yield win
|
|
|
|
|
|
win.autosave_timer.stop()
|
|
|
|
|
|
win.sync_timer.stop()
|
|
|
|
|
|
win.close()
|
|
|
|
|
|
win.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- окно целиком ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
def page_of(window, title: str):
|
|
|
|
|
|
"""Найти экран по названию.
|
|
|
|
|
|
|
|
|
|
|
|
Раньше тесты адресовали экраны по номеру в списке, и любой новый раздел
|
|
|
|
|
|
в боковом меню ломал половину проверок.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return next(page for name, page in window.pages if name == title)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
def test_window_builds_with_all_pages(window):
|
|
|
|
|
|
titles = [title for title, _ in window.pages]
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
assert titles == [
|
|
|
|
|
|
"Сводка", "Закупки", "Продажи", "Долги",
|
|
|
|
|
|
"Товары", "Статистика", "Журнал", "Настройки",
|
|
|
|
|
|
]
|
|
|
|
|
|
assert window.stack.count() == len(titles)
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_page_refreshes_without_errors(window):
|
|
|
|
|
|
for index in range(len(window.pages)):
|
|
|
|
|
|
window.sidebar.setCurrentRow(index)
|
|
|
|
|
|
window.pages[index][1].refresh()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_changed_rebuilds_the_report(window, buns):
|
|
|
|
|
|
before = window.report.summary.owed_to_bakery
|
|
|
|
|
|
journal.add_bakery_payment(window.vault, 100, batch_id=window.batch.id)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
assert window.report.summary.owed_to_bakery == before - 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_autosave_writes_only_after_a_change(window):
|
|
|
|
|
|
window.save_now()
|
|
|
|
|
|
stamp = window.vault.path.stat().st_mtime_ns
|
|
|
|
|
|
window._autosave()
|
|
|
|
|
|
assert window.vault.path.stat().st_mtime_ns == stamp # файл не трогали
|
|
|
|
|
|
|
|
|
|
|
|
journal.create_product(window.vault, "Булка с изюмом", cost_price=22, retail_price=38)
|
|
|
|
|
|
assert window.vault.revision != window._saved_revision
|
|
|
|
|
|
window._autosave()
|
|
|
|
|
|
assert window.vault.revision == window._saved_revision
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_is_skipped_without_a_remote(window):
|
|
|
|
|
|
"""Без адреса репозитория фоновый тик не должен ничего делать."""
|
|
|
|
|
|
window.sync_now(quiet=True)
|
|
|
|
|
|
assert window._syncing is False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 02:30:42 +07:00
|
|
|
|
def test_git_always_targets_the_data_folder(window):
|
|
|
|
|
|
"""Приложение не должно распоряжаться репозиторием с исходниками.
|
|
|
|
|
|
|
|
|
|
|
|
exe обычно лежит в корне клона с кодом, и папка data — внутри него.
|
|
|
|
|
|
Если git начать с корня приложения, оно перепишет исходникам origin
|
|
|
|
|
|
и запушит их в репозиторий данных. Ровно это однажды и произошло.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app import paths
|
|
|
|
|
|
|
|
|
|
|
|
sync = window._git()
|
|
|
|
|
|
assert sync.repo_dir == paths.data_dir()
|
|
|
|
|
|
assert sync.repo_dir != paths.app_root()
|
|
|
|
|
|
assert sync.rel_path == "vault.fmdb"
|
|
|
|
|
|
assert "/" not in sync.rel_path # база лежит в корне своего репозитория
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_other_place_builds_git_by_hand():
|
|
|
|
|
|
"""Единственная точка создания GitSync — MainWindow._git()."""
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
source = Path("app/ui/main_window.py").read_text(encoding="utf-8")
|
|
|
|
|
|
# Одно вхождение — внутри самого _git(); остальные зовут его.
|
|
|
|
|
|
assert source.count("GitSync(") == 1
|
|
|
|
|
|
assert "paths.app_root()" not in source
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
# --- таблицы наполняются --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_batches_table_shows_the_batch(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Закупки")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.table.rowCount() == 1
|
|
|
|
|
|
assert page.table.item(0, 0).text() == "1"
|
|
|
|
|
|
assert "850" in page.table.item(0, 4).text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_batch_details_show_coverage(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Закупки")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
page.table.selectRow(0)
|
|
|
|
|
|
page._show_details()
|
|
|
|
|
|
|
|
|
|
|
|
assert "Партия" in page.details_title.text()
|
|
|
|
|
|
assert page.progress.value() == 17 # 150 из 850
|
|
|
|
|
|
assert page.contents.rowCount() == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sales_table_lists_every_kind(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Продажи")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.table.rowCount() == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sales_filter_narrows_the_list(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Продажи")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.filter.setCurrentIndex(page.filter.findData(KIND_SELF))
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.table.rowCount() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_debts_tree_groups_by_person(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Долги")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.tree.topLevelItemCount() == 1
|
|
|
|
|
|
|
|
|
|
|
|
person = page.tree.topLevelItem(0)
|
|
|
|
|
|
assert person.text(0) == "Вася"
|
|
|
|
|
|
assert "100" in person.text(4)
|
|
|
|
|
|
assert person.childCount() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_products_table_shows_stock_and_margin(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Товары")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
names = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
|
|
|
|
|
|
assert "Булка с маком" in names
|
|
|
|
|
|
|
|
|
|
|
|
row = names.index("Булка с маком")
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
assert "15" in page.table.item(row, 5).text() # наценка: 35 − 20
|
|
|
|
|
|
assert page.table.item(row, 6).text() == "5" # 10 закуплено − 5 отдано
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_archived_products_are_hidden_by_default(window, buns):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Товары")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
journal.update_product(window.vault, buns["мак"].id, archived=True)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
|
|
|
|
|
visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
|
|
|
|
|
|
assert "Булка с маком" not in visible
|
|
|
|
|
|
|
|
|
|
|
|
page.show_archived.setChecked(True)
|
|
|
|
|
|
visible = [page.table.item(r, 0).text() for r in range(page.table.rowCount())]
|
|
|
|
|
|
assert "Булка с маком" in visible
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- сводка ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dashboard_renders_metrics_and_deadline(window):
|
|
|
|
|
|
from PySide6.QtWidgets import QLabel, QProgressBar
|
|
|
|
|
|
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Сводка")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
|
|
|
|
|
|
assert "Долг пекарне" in texts
|
|
|
|
|
|
assert "850,00 ₽" in texts # вся себестоимость партии ещё не возвращена
|
|
|
|
|
|
assert "Мне должны" in texts
|
|
|
|
|
|
assert "100,00 ₽" in texts # долг Васи
|
Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.
Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».
Фасовки тоже могут быть дробными: бутылка 1,5 л.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:20:29 +07:00
|
|
|
|
# Остаток показан деньгами и позициями, а не суммой разных единиц.
|
|
|
|
|
|
assert "610,00 ₽" in texts
|
|
|
|
|
|
assert "позиций: 3" in texts
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
bars = page.body.findChildren(QProgressBar)
|
|
|
|
|
|
assert len(bars) == 1
|
|
|
|
|
|
assert bars[0].value() == 17 # собрано 150 из 850
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dashboard_shows_a_warning_when_oversold(window, buns):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Сводка")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
journal.create_sale(
|
|
|
|
|
|
window.vault, window.report.today, KIND_RETAIL,
|
|
|
|
|
|
[{"product_id": buns["повидло"].id, "qty": 99, "unit_price": 50}],
|
|
|
|
|
|
)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
assert any("больше" in warning for warning in window.report.warnings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- журнал ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_journal_page_lists_entries(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Журнал")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.table.rowCount() == len(window.vault.doc.journal)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_journal_search_filters(window, buns):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Журнал")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
|
|
|
|
|
page.search.setText("цены изменены")
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.table.rowCount() == 1
|
|
|
|
|
|
assert "20,00 ₽ → 25,00 ₽" in page.table.item(0, 4).text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_journal_search_matches_what_is_displayed(window, buns):
|
|
|
|
|
|
"""Формат на экране и то, что ищется, должны совпадать до пробела."""
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Журнал")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
journal.change_prices(window.vault, buns["мак"].id, cost_price=1500, retail_price=2000)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
|
|
|
|
|
page.search.setText("1 500,00")
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.table.rowCount() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_journal_entity_filter(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Журнал")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.entity.setCurrentIndex(page.entity.findData("batch"))
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.table.rowCount() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_describe_renders_a_transition():
|
|
|
|
|
|
from app.models import Change, JournalEntry
|
|
|
|
|
|
|
|
|
|
|
|
entry = JournalEntry(
|
|
|
|
|
|
id="j_1", ts="", host="", action="product.price_change",
|
|
|
|
|
|
entity="product", entity_id="p_1", label="Булка",
|
|
|
|
|
|
changes=[Change("себестоимость", "20,00 ₽", "25,00 ₽")],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert describe(entry) == "себестоимость: 20,00 ₽ → 25,00 ₽"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- формы ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_batch_dialog_round_trips_lines(window):
|
|
|
|
|
|
dialog = BatchDialog(window, window.batch)
|
|
|
|
|
|
lines = dialog.lines.lines("unit_cost")
|
|
|
|
|
|
|
|
|
|
|
|
assert len(lines) == 3
|
|
|
|
|
|
assert dialog.lines.total() == Decimal("850.00")
|
|
|
|
|
|
assert lines[0]["unit_cost"] == Decimal("30.00")
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_batch_dialog_defaults_the_deadline_from_settings(window):
|
|
|
|
|
|
window.vault.doc.settings.default_period_days = 21
|
|
|
|
|
|
dialog = BatchDialog(window)
|
|
|
|
|
|
assert (dialog.due.get_date() - dialog.date.get_date()).days == 21
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_batch_dialog_deadline_follows_the_purchase_date(window):
|
|
|
|
|
|
dialog = BatchDialog(window)
|
|
|
|
|
|
dialog.date.set_date(dialog.date.get_date() + timedelta(days=5))
|
|
|
|
|
|
days = window.vault.doc.settings.default_period_days
|
|
|
|
|
|
assert (dialog.due.get_date() - dialog.date.get_date()).days == days
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sale_dialog_substitutes_price_by_kind(window, buns):
|
|
|
|
|
|
"""Ради этого и заведены типы: другу — себестоимость, себе — ноль."""
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id)
|
|
|
|
|
|
|
|
|
|
|
|
dialog.kind.setCurrentIndex(0) # розница
|
|
|
|
|
|
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("35.00")
|
|
|
|
|
|
|
|
|
|
|
|
dialog.kind.setCurrentIndex(1) # другу по себестоимости
|
|
|
|
|
|
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("20.00")
|
|
|
|
|
|
|
|
|
|
|
|
dialog.kind.setCurrentIndex(2) # съел сам
|
|
|
|
|
|
assert dialog.lines.lines("unit_price")[0]["unit_price"] == Decimal("0.00")
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sale_dialog_disables_payment_for_consumption(window):
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
dialog.kind.setCurrentIndex(2) # съел сам
|
|
|
|
|
|
assert dialog.paid.isEnabled() is False
|
|
|
|
|
|
assert dialog.paid.value_decimal() == 0
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sale_dialog_prefills_full_payment(window, buns):
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
dialog.lines.table.cellWidget(0, 0).select_product(buns["мак"].id)
|
|
|
|
|
|
dialog.lines.table.cellWidget(0, 1).set_decimal(4)
|
|
|
|
|
|
|
|
|
|
|
|
assert dialog.paid.value_decimal() == Decimal("140.00") # 4 × 35
|
|
|
|
|
|
assert "полностью" in dialog.paid_hint.text()
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_price_source_for_each_kind(buns):
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
bun = buns["мак"]
|
|
|
|
|
|
assert price_source_for(KIND_RETAIL)(bun, "шт") == Decimal("35.00")
|
|
|
|
|
|
assert price_source_for(KIND_FRIEND)(bun, "шт") == Decimal("20.00")
|
|
|
|
|
|
assert price_source_for(KIND_SELF)(bun, "шт") == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_price_source_uses_the_pack_price(vault, buns):
|
|
|
|
|
|
"""Пачка стоит своих денег, а не десяти поштучных цен."""
|
|
|
|
|
|
from app import journal as j
|
|
|
|
|
|
|
|
|
|
|
|
cookies = j.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
|
|
|
|
|
|
j.set_packs(vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
|
|
|
|
|
|
cookies = vault.doc.product(cookies.id)
|
|
|
|
|
|
|
|
|
|
|
|
assert price_source_for(KIND_RETAIL)(cookies, "шт") == Decimal("30.00")
|
|
|
|
|
|
assert price_source_for(KIND_RETAIL)(cookies, "пачка") == Decimal("200.00")
|
|
|
|
|
|
assert price_source_for(KIND_FRIEND)(cookies, "пачка") == Decimal("200.00") # 10 × 20
|
2026-08-22 21:29:53 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_price_dialog_shows_history(window, buns):
|
|
|
|
|
|
journal.change_prices(window.vault, buns["мак"].id, cost_price=25, retail_price=40)
|
|
|
|
|
|
dialog = PriceDialog("₽", window.vault.doc.product(buns["мак"].id))
|
|
|
|
|
|
|
|
|
|
|
|
assert dialog.cost.value_decimal() == Decimal("25.00")
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_product_dialog_hides_prices_when_editing(window, buns):
|
|
|
|
|
|
"""Цены правятся только через историю — иначе она бы врала."""
|
|
|
|
|
|
editing = ProductDialog("₽", window.vault.doc.product(buns["мак"].id))
|
|
|
|
|
|
assert not hasattr(editing, "cost")
|
|
|
|
|
|
editing.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
creating = ProductDialog("₽")
|
|
|
|
|
|
assert hasattr(creating, "cost")
|
|
|
|
|
|
creating.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
Отдельный репозиторий для базы, фасовки товара, ввод истории
Три правки по замечаниям.
1. База и код разъехались по разным репозиториям. Автокоммит и раньше
трогал только vault.fmdb, но лежал он в той же ветке, что и исходники,
поэтому пуш тащил всю историю программы. Теперь data/ — самостоятельный
клон food-records со своим .git, а исходники его игнорируют целиком.
При первом запуске приложение встаёт на уже существующую историю
сервера, а не заводит параллельную.
2. Фасовки товара. Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно
за 30 ₽ — один товар с двумя фасовками. Остатки, себестоимость и FIFO
считаются в базовых единицах, поэтому поштучные продажи вычитаются из
купленных пачек. Размер фасовки хранится в документе слепком.
Миграция схемы 1→2 не меняет поведение уже заведённых данных.
3. Быстрый ввод продаж за период: выбор диапазона дат, строка таблицы —
отдельная продажа, ввод с клавиатуры, запись всех разом. Незнакомое имя
заводится как контрагент, повтор периода подсвечивается.
Тесты больше не имеют настроенного remote, чтобы не ходить в сеть.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:25:39 +07:00
|
|
|
|
def test_lines_editor_switches_price_with_the_pack(window):
|
|
|
|
|
|
"""Выбрал пачку — цена и подпись меняются, товар остаётся тем же."""
|
|
|
|
|
|
from app import journal as j
|
|
|
|
|
|
|
|
|
|
|
|
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
|
|
|
|
|
|
j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
editor = dialog.lines
|
|
|
|
|
|
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(cookies.id)
|
|
|
|
|
|
|
|
|
|
|
|
uom = editor.table.cellWidget(0, editor.COL_UOM)
|
|
|
|
|
|
assert [uom.itemText(i) for i in range(uom.count())] == ["шт", "пачка"]
|
|
|
|
|
|
assert uom.isEnabled() is True
|
|
|
|
|
|
assert editor.lines("unit_price")[0]["unit_price"] == Decimal("30.00")
|
|
|
|
|
|
|
|
|
|
|
|
uom.setCurrentText("пачка")
|
|
|
|
|
|
line = editor.lines("unit_price")[0]
|
|
|
|
|
|
assert line["uom"] == "пачка"
|
|
|
|
|
|
assert line["unit_price"] == Decimal("200.00")
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pack_column_is_disabled_without_packs(window, buns):
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
editor = dialog.lines
|
|
|
|
|
|
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id)
|
|
|
|
|
|
|
|
|
|
|
|
uom = editor.table.cellWidget(0, editor.COL_UOM)
|
|
|
|
|
|
assert uom.currentText() == "шт"
|
|
|
|
|
|
assert uom.isEnabled() is False
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_packs_editor_round_trip(window):
|
|
|
|
|
|
from app import journal as j
|
|
|
|
|
|
from app.ui.page_products import ProductDialog as PD
|
|
|
|
|
|
|
|
|
|
|
|
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=20, retail_price=30)
|
|
|
|
|
|
j.set_packs(window.vault, cookies.id, [{"name": "пачка", "size": 10, "retail_price": 200}])
|
|
|
|
|
|
|
|
|
|
|
|
dialog = PD("₽", window.vault.doc.product(cookies.id))
|
|
|
|
|
|
assert dialog.packs.packs() == [
|
|
|
|
|
|
{"name": "пачка", "size": Decimal("10.000"), "retail_price": Decimal("200.00")}
|
|
|
|
|
|
]
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
def test_lines_editor_add_and_remove(window, buns):
|
|
|
|
|
|
dialog = BatchDialog(window)
|
|
|
|
|
|
editor = dialog.lines
|
|
|
|
|
|
start = editor.table.rowCount()
|
|
|
|
|
|
|
|
|
|
|
|
editor.add_line(buns["мак"].id, 7, 20)
|
|
|
|
|
|
assert len(editor.lines("unit_cost")) == start + 1
|
|
|
|
|
|
|
|
|
|
|
|
editor.table.setCurrentCell(editor.table.rowCount() - 1, 0)
|
|
|
|
|
|
editor.remove_current()
|
|
|
|
|
|
assert len(editor.lines("unit_cost")) == start
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
# --- статистика -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_page_has_every_section(window):
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
titles = [page.tabs.tabText(i) for i in range(page.tabs.count())]
|
|
|
|
|
|
assert titles == ["Периоды", "Недели", "Месяцы", "Годы", "Товары", "Люди"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_sections_all_render(window):
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
for index in range(page.tabs.count()):
|
|
|
|
|
|
page.tabs.setCurrentIndex(index)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.sections[index][1].table.rowCount() >= 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_periods_show_the_batch(window):
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
page.tabs.setCurrentIndex(0)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
tab = page.sections[0][1]
|
|
|
|
|
|
assert tab.table.rowCount() == 1
|
|
|
|
|
|
assert tab.table.item(0, 0).text() == "№1"
|
|
|
|
|
|
assert "850" in tab.table.item(0, 4).text() # долг по партии
|
|
|
|
|
|
assert "Периодов: 1" in tab.headline.text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_months_carry_a_totals_row(window):
|
|
|
|
|
|
"""Последняя строка — итог, иначе цифры приходится складывать в уме."""
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
page.tabs.setCurrentIndex(2)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
tab = page.sections[2][1]
|
|
|
|
|
|
assert tab.table.rowCount() == len(tab.buckets) + 1
|
|
|
|
|
|
assert tab.table.item(tab.table.rowCount() - 1, 0).text() == "Итого"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_metric_switch_redraws_the_chart(window):
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
page.tabs.setCurrentIndex(2)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
tab = page.sections[2][1]
|
|
|
|
|
|
tab.metric.setCurrentIndex(1) # Выручка
|
|
|
|
|
|
assert "Выручка за всё время" in tab.headline.text()
|
|
|
|
|
|
assert len(tab.chart.bars) == len(tab.buckets)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_products_rank_by_margin(window):
|
|
|
|
|
|
"""Таблица и график должны отвечать одинаково: сортировка по заработку."""
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
page.tabs.setCurrentIndex(4)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
tab = page.sections[4][1]
|
|
|
|
|
|
names = [tab.table.item(r, 0).text() for r in range(tab.table.rowCount())]
|
|
|
|
|
|
assert names[0] == "Булка с повидлом" # единственная с наценкой
|
|
|
|
|
|
assert names[0] == tab.chart.bars[0].label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_people_table_matches_its_chart(window):
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
page.tabs.setCurrentIndex(5)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
tab = page.sections[5][1]
|
|
|
|
|
|
names = [tab.table.item(r, 0).text() for r in range(tab.table.rowCount())]
|
|
|
|
|
|
assert names == [bar.label for bar in tab.chart.bars]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_people_show_the_debt(window):
|
|
|
|
|
|
page = page_of(window, "Статистика")
|
|
|
|
|
|
page.tabs.setCurrentIndex(5)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
tab = page.sections[5][1]
|
|
|
|
|
|
rows = {tab.table.item(r, 0).text(): r for r in range(tab.table.rowCount())}
|
|
|
|
|
|
assert "100,00 ₽" in tab.table.item(rows["Вася"], 5).text()
|
|
|
|
|
|
assert "должны в сумме: 100,00 ₽" in tab.headline.text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stats_survive_an_empty_database(qapp, vault):
|
|
|
|
|
|
"""Пустая база не должна ронять ни один разрез."""
|
|
|
|
|
|
from app.ui.main_window import MainWindow as MW
|
|
|
|
|
|
|
|
|
|
|
|
win = MW(vault)
|
|
|
|
|
|
win.autosave_timer.stop()
|
|
|
|
|
|
win.sync_timer.stop()
|
|
|
|
|
|
|
|
|
|
|
|
page = next(p for name, p in win.pages if name == "Статистика")
|
|
|
|
|
|
for index in range(page.tabs.count()):
|
|
|
|
|
|
page.tabs.setCurrentIndex(index)
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.sections[index][1].table.rowCount() == 0
|
|
|
|
|
|
|
|
|
|
|
|
win.close()
|
|
|
|
|
|
win.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_chart_handles_losses(qapp):
|
|
|
|
|
|
"""Убыток — нормальная величина, столбик должен уходить вниз от нуля."""
|
|
|
|
|
|
from decimal import Decimal as D
|
|
|
|
|
|
|
|
|
|
|
|
from app.ui.chart import Bar, BarChart
|
|
|
|
|
|
|
|
|
|
|
|
chart = BarChart()
|
|
|
|
|
|
chart.set_bars([Bar("янв", D("100")), Bar("фев", D("-40")), Bar("мар", D("0"))])
|
|
|
|
|
|
chart.resize(400, 200)
|
|
|
|
|
|
chart.grab() # отрисовка не должна падать
|
|
|
|
|
|
assert len(chart.bars) == 3
|
|
|
|
|
|
chart.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_chart_handles_all_zeroes(qapp):
|
|
|
|
|
|
from decimal import Decimal as D
|
|
|
|
|
|
|
|
|
|
|
|
from app.ui.chart import Bar, BarChart
|
|
|
|
|
|
|
|
|
|
|
|
chart = BarChart()
|
|
|
|
|
|
chart.set_bars([Bar("янв", D("0")), Bar("фев", D("0"))])
|
|
|
|
|
|
chart.resize(400, 200)
|
|
|
|
|
|
chart.grab()
|
|
|
|
|
|
chart.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_chart_says_so(qapp):
|
|
|
|
|
|
from app.ui.chart import BarChart
|
|
|
|
|
|
|
|
|
|
|
|
chart = BarChart()
|
|
|
|
|
|
chart.resize(400, 200)
|
|
|
|
|
|
chart.grab()
|
|
|
|
|
|
assert chart.bars == []
|
|
|
|
|
|
chart.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
# --- чаевые ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_overpayment_moves_into_tips(window, buns):
|
|
|
|
|
|
"""Булка 70, дали 100 — разница должна уехать в чаевые сама."""
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
editor = dialog.lines
|
|
|
|
|
|
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id)
|
|
|
|
|
|
editor.table.cellWidget(0, editor.COL_QTY).set_decimal(2)
|
|
|
|
|
|
|
|
|
|
|
|
dialog.paid.set_decimal(100)
|
|
|
|
|
|
dialog._absorb_overpayment()
|
|
|
|
|
|
|
|
|
|
|
|
assert dialog.paid.value_decimal() == Decimal("70.00")
|
|
|
|
|
|
assert dialog.tip.value_decimal() == Decimal("30.00")
|
|
|
|
|
|
assert "чаевые" in dialog.paid_hint.text().lower()
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_underpayment_is_left_as_debt(window, buns):
|
|
|
|
|
|
"""Недоплата — это долг, а не отрицательные чаевые."""
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
editor = dialog.lines
|
|
|
|
|
|
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(buns["мак"].id)
|
|
|
|
|
|
editor.table.cellWidget(0, editor.COL_QTY).set_decimal(2)
|
|
|
|
|
|
|
|
|
|
|
|
dialog.paid.set_decimal(40)
|
|
|
|
|
|
dialog._absorb_overpayment()
|
|
|
|
|
|
|
|
|
|
|
|
assert dialog.paid.value_decimal() == Decimal("40.00")
|
|
|
|
|
|
assert dialog.tip.value_decimal() == 0
|
|
|
|
|
|
assert "долг" in dialog.paid_hint.text().lower()
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_consumption_disables_tips(window, buns):
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
dialog.kind.setCurrentIndex(2) # съел сам
|
|
|
|
|
|
assert dialog.tip.isEnabled() is False
|
|
|
|
|
|
assert dialog.tip.value_decimal() == 0
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_existing_sale_can_have_its_tip_edited(window, buns):
|
|
|
|
|
|
"""Сдачу могли оставить и после того, как продажу занесли."""
|
|
|
|
|
|
sale = window.vault.doc.sales[0]
|
|
|
|
|
|
journal.set_sale_tip(window.vault, sale.id, 15)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
|
|
|
|
|
dialog = SaleDialog(window, sale)
|
|
|
|
|
|
assert dialog.tip.value_decimal() == Decimal("15.00")
|
|
|
|
|
|
assert dialog.paid is None # платежи правятся на экране долгов
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sales_table_shows_tips(window, buns):
|
|
|
|
|
|
sale = window.vault.doc.sales[0]
|
|
|
|
|
|
journal.set_sale_tip(window.vault, sale.id, 25)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Продажи")
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
tips = [page.table.item(r, 6).text() for r in range(page.table.rowCount())]
|
|
|
|
|
|
assert any("25,00" in t for t in tips)
|
|
|
|
|
|
# У продаж без чаевых колонка пустая, а не «0,00 ₽».
|
|
|
|
|
|
assert "" in tips
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dashboard_mentions_tips_only_when_there_are_any(window, buns):
|
|
|
|
|
|
from PySide6.QtWidgets import QLabel
|
|
|
|
|
|
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Сводка")
|
Чаевые
Деньги сверх стоимости товара. Хранятся на продаже отдельным полем, а не
внутри платежей: иначе чаевые раздували бы «оплачено», и продажа выглядела
бы закрытой, когда за булки ещё должны.
Ввёл оплату больше суммы — булка 70, дали 100 — разница уходит в чаевые
сама. Разбор делается по окончании ввода, а не на каждом нажатии, иначе
поле дёргалось бы посреди набора. То же при приёме оплаты по долгу:
заплатили больше, чем оставалось, — излишек становится чаевыми, а не
теряется, как было раньше.
В расчётах чаевые идут в покрытие партии (это живые деньги, ими так же
рассчитываются с пекарней) и целиком добавляются в прибыль, но не входят
в выручку и не создают долга. Продажа, списанная с нескольких партий,
делит чаевые между ними пропорционально выручке.
По съеденному и подаренному чаевых не бывает — поле отключается.
Миграция схемы 2→3 проставляет ноль: у кого чаевых нет, тот появления
этой фичи не заметит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 04:56:26 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
|
|
|
|
|
|
assert "чаевые" not in texts.lower()
|
|
|
|
|
|
|
|
|
|
|
|
journal.set_sale_tip(window.vault, window.vault.doc.sales[0].id, 40)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
texts = " ".join(label.text() for label in page.body.findChildren(QLabel))
|
|
|
|
|
|
assert "чаевые 40,00 ₽" in texts.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.
Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».
Фасовки тоже могут быть дробными: бутылка 1,5 л.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:20:29 +07:00
|
|
|
|
# --- дробные количества ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def type_into(spin, text: str):
|
|
|
|
|
|
"""Набрать текст в поле так, как это делает пользователь."""
|
|
|
|
|
|
spin.clear()
|
|
|
|
|
|
spin.lineEdit().setText(text)
|
|
|
|
|
|
spin.interpretText()
|
|
|
|
|
|
return spin
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dot_is_accepted_as_decimal_separator(qapp):
|
|
|
|
|
|
"""На цифровой клавиатуре точка, а локаль русская — ждёт запятую.
|
|
|
|
|
|
|
|
|
|
|
|
Без нормализации символ молча не появлялся бы в поле, и выглядело бы это
|
|
|
|
|
|
так, будто дробные значения вводить нельзя вообще.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from PySide6.QtGui import QValidator
|
|
|
|
|
|
|
|
|
|
|
|
from app.ui.widgets import MoneySpin, QtySpin
|
|
|
|
|
|
|
|
|
|
|
|
for spin in (QtySpin(), MoneySpin()):
|
|
|
|
|
|
for text in ("0,5", "0.5", "12.75", "12,75"):
|
|
|
|
|
|
state, fixed, _ = spin.validate(text, len(text))
|
|
|
|
|
|
assert state != QValidator.Invalid, f"{type(spin).__name__} отверг {text!r}"
|
|
|
|
|
|
assert "." not in fixed
|
|
|
|
|
|
spin.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_both_separators_give_the_same_value(qapp):
|
|
|
|
|
|
from app.ui.widgets import QtySpin
|
|
|
|
|
|
|
|
|
|
|
|
assert type_into(QtySpin(), "0.5").value_decimal() == Decimal("0.500")
|
|
|
|
|
|
assert type_into(QtySpin(), "0,5").value_decimal() == Decimal("0.500")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_group_separators_are_tolerated(qapp):
|
|
|
|
|
|
from app.ui.widgets import MoneySpin
|
|
|
|
|
|
|
|
|
|
|
|
assert type_into(MoneySpin(), "1 200,50").value_decimal() == Decimal("1200.50")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_letters_are_still_rejected(qapp):
|
|
|
|
|
|
from PySide6.QtGui import QValidator
|
|
|
|
|
|
|
|
|
|
|
|
from app.ui.widgets import QtySpin
|
|
|
|
|
|
|
|
|
|
|
|
state, _, _ = QtySpin().validate("abc", 3)
|
|
|
|
|
|
assert state == QValidator.Invalid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fractional_line_round_trips_through_the_form(window):
|
|
|
|
|
|
"""Пол-литра сока, введённые в форме, должны дойти до документа."""
|
|
|
|
|
|
from app import journal as j
|
|
|
|
|
|
|
|
|
|
|
|
juice = j.create_product(window.vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
|
|
|
|
|
dialog = SaleDialog(window)
|
|
|
|
|
|
editor = dialog.lines
|
|
|
|
|
|
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(juice.id)
|
|
|
|
|
|
type_into(editor.table.cellWidget(0, editor.COL_QTY), "0.5")
|
|
|
|
|
|
|
|
|
|
|
|
line = editor.lines("unit_price")[0]
|
|
|
|
|
|
assert line["qty"] == Decimal("0.500")
|
|
|
|
|
|
assert editor.total() == Decimal("60.00") # 0,5 × 120
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fractional_pack_size_round_trips(window):
|
|
|
|
|
|
from app import journal as j
|
|
|
|
|
|
from app.ui.page_products import ProductDialog as PD
|
|
|
|
|
|
|
|
|
|
|
|
juice = j.create_product(window.vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
|
|
|
|
|
j.set_packs(window.vault, juice.id, [{"name": "бутылка", "size": "1.5", "retail_price": 170}])
|
|
|
|
|
|
|
|
|
|
|
|
dialog = PD("₽", window.vault.doc.product(juice.id))
|
|
|
|
|
|
assert dialog.packs.packs()[0]["size"] == Decimal("1.500")
|
|
|
|
|
|
dialog.deleteLater()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fractional_quantity_is_displayed_without_trailing_zeros(window):
|
|
|
|
|
|
from app import journal as j
|
|
|
|
|
|
from app.models import KIND_RETAIL as RETAIL
|
|
|
|
|
|
|
|
|
|
|
|
juice = j.create_product(window.vault, "Сок", unit="л", cost_price=80, retail_price=120)
|
|
|
|
|
|
j.create_sale(
|
|
|
|
|
|
window.vault, window.report.today, RETAIL,
|
|
|
|
|
|
[{"product_id": juice.id, "qty": "0.5", "unit_price": 120}],
|
|
|
|
|
|
)
|
|
|
|
|
|
window.changed()
|
|
|
|
|
|
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Продажи")
|
Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.
Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».
Фасовки тоже могут быть дробными: бутылка 1,5 л.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:20:29 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
texts = [page.table.item(r, 3).text() for r in range(page.table.rowCount())]
|
|
|
|
|
|
assert any("Сок × 0,5 л" in t for t in texts)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 21:29:53 +07:00
|
|
|
|
# --- настройки ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_shows_database_facts(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Настройки")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert "товаров 3" in page.db_info.text()
|
|
|
|
|
|
assert "закупок 1" in page.db_info.text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_settings_saves_git_without_wiping_the_token(window):
|
|
|
|
|
|
"""Пустое поле токена означает «не трогать», а не «стереть»."""
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Настройки")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
journal.update_settings(window.vault, git={"token": "glpat-keepme"})
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
|
|
|
|
|
|
page.remote.setText("https://gt.ser.gay/kizya/food-market.git")
|
|
|
|
|
|
page.token.clear()
|
|
|
|
|
|
page.save_git()
|
|
|
|
|
|
|
|
|
|
|
|
assert window.vault.doc.settings.git.token == "glpat-keepme"
|
|
|
|
|
|
assert window.vault.doc.settings.git.remote_url == "https://gt.ser.gay/kizya/food-market.git"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_conflict_block_is_hidden_until_there_is_one(window):
|
Раздел статистики
Шесть разрезов: периоды (заданные вручную, то есть партии), недели, месяцы,
годы, товары и люди. У каждого график и таблица с итоговой строкой.
Главное решение — две базы расчёта одновременно. Смешивать «когда продал»
и «когда получил деньги» нельзя: ответы расходятся, как только появляется
хоть один долг. Выручка, наценка, съеденное и чаевые считаются по дате
продажи, «Собрано» — по дате платежа, «Закуплено» и «Отдано пекарне» — по
своим датам. У разреза «Периоды» основание третье, привязка к партии,
потому что вопрос там другой: покрыл ли я её к сроку.
Количество нигде не сводится в одно число — только по единицам, «1,5 л ·
4 шт». Сумма литров с килограммами выглядит как настоящая цифра, но смысла
не имеет; ровно эту ошибку пришлось чинить и в разрезе по партиям.
График на QPainter: QtCharts исключён из сборки, он тянет десятки мегабайт
ради одной диаграммы. Убыток рисуется вниз от нулевой линии. Больше 24
корзин на график не помещается, и про срез написано над ним — молча
показанная часть выглядела бы как всё.
Тесты экранов больше не адресуют разделы по номеру в меню: новый раздел
ломал половину проверок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:33:11 +07:00
|
|
|
|
page = page_of(window, "Настройки")
|
2026-08-22 21:29:53 +07:00
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.conflict_box.isVisibleTo(page) is False
|
|
|
|
|
|
|
|
|
|
|
|
window.conflict_message = "версии разошлись"
|
|
|
|
|
|
page.refresh()
|
|
|
|
|
|
assert page.conflict_box.isVisibleTo(page) is True
|