From 1a97fee872c17026dc1f8d3ed88cb5f51d8c63e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 22:20:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D1=80=D0=BE=D0=B1=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0:?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB-=D0=BB=D0=B8=D1=82=D1=80=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0,=201,125=20=D0=BA=D0=B3=20=D1=81=D1=8B=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Количества и раньше хранились в Decimal с тремя знаками, но до документа они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча не появлялся, и выглядело это так, будто дробные вводить нельзя вообще. Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже. Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось общее количество остатка по всем товарам, то есть литры с килограммами и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка по единицам осталась в таблице «Остатки». Фасовки тоже могут быть дробными: бутылка 1,5 л. Co-Authored-By: Claude Opus 5 --- README.md | 14 ++++ app/ledger.py | 5 ++ app/ui/page_dashboard.py | 7 +- app/ui/widgets.py | 32 +++++++-- tests/test_ledger.py | 136 ++++++++++++++++++++++++++++++++++++++ tests/test_quick_sales.py | 21 ++++++ tests/test_ui_smoke.py | 101 ++++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7c2cfcc..1cd40ec 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,20 @@ - **Журнал** — что менялось в базе, когда и с какой машины. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. +### Дробные количества + +Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра +или полбулки учитываются как есть. Единица измерения у товара произвольная — +`шт`, `л`, `кг`. + +Поля ввода принимают и запятую, и точку: на цифровой клавиатуре точка, а +интерфейс русский и ждёт запятую, так что точка молча заменяется на запятую. +Без этого символ просто не появлялся бы в поле. + +Общее количество остатка по всем товарам сразу нигде не показывается — +складывать литры с килограммами бессмысленно. В сводке остаток выражен деньгами +и числом позиций, а разбивка по товарам есть в таблице «Остатки». + ### Фасовки Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — это **один diff --git a/app/ledger.py b/app/ledger.py index e85c2e3..eb64052 100644 --- a/app/ledger.py +++ b/app/ledger.py @@ -139,8 +139,12 @@ class Summary: cash_collected: Decimal gross_margin: Decimal consumed_cost: Decimal + # Осторожно: сумма по всем товарам сразу. Осмысленна, только когда единица + # измерения у всех одна. Литры с килограммами складывать нельзя, поэтому + # в сводке показывается не она, а stock_cost и stock_positions. stock_qty: Decimal stock_cost: Decimal + stock_positions: int overdue_count: int next_due: BatchReport | None @@ -476,6 +480,7 @@ def _summary( consumed_cost=m.money(consumed), stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)), stock_cost=m.money(sum((r.stock_cost for r in batch_reports), m.ZERO)), + stock_positions=len({pid for r in batch_reports for pid in r.stock}), overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE), next_due=next_due, ) diff --git a/app/ui/page_dashboard.py b/app/ui/page_dashboard.py index 4f6fff9..7e2e936 100644 --- a/app/ui/page_dashboard.py +++ b/app/ui/page_dashboard.py @@ -99,9 +99,12 @@ class DashboardPage(QWidget): theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT, ), ( + # Показываем деньги, а не количество: у сока литры, у сыра + # килограммы, у булок штуки — общая сумма штук бессмысленна. + # Разбивка по товарам есть ниже в «Остатках». "Остаток на складе", - m.fmt_qty(summary.stock_qty), - f"по себестоимости {m.fmt_money(summary.stock_cost, currency)}", + m.fmt_money(summary.stock_cost, currency), + f"позиций: {summary.stock_positions}" if summary.stock_positions else "пусто", theme.TEXT, ), ] diff --git a/app/ui/widgets.py b/app/ui/widgets.py index 9f475b3..06a17f4 100644 --- a/app/ui/widgets.py +++ b/app/ui/widgets.py @@ -37,7 +37,27 @@ MAX_MONEY = 99_999_999.0 # --- поля ввода ----------------------------------------------------------- -class MoneySpin(QDoubleSpinBox): +class DecimalSpin(QDoubleSpinBox): + """Числовое поле, принимающее и точку, и запятую. + + Интерфейс русский, поэтому разделитель целой и дробной части — запятая. + Но на цифровой клавиатуре клавиша даёт точку, и стандартный валидатор + Qt её молча отвергает: символ просто не появляется в поле, и человек + решает, что дробные значения вводить нельзя. + """ + + def validate(self, text: str, pos: int): + # Пробелы — это разделители разрядов; при вводе они только мешают. + fixed = text.replace(".", ",").replace(" ", "").replace(" ", "") + if fixed != text: + pos = min(pos, len(fixed)) + return super().validate(fixed, pos) + + def fixup(self, text: str) -> str: + return super().fixup(text.replace(".", ",")) + + +class MoneySpin(DecimalSpin): def __init__(self, currency: str = "₽", parent=None): super().__init__(parent) self.setDecimals(2) @@ -53,14 +73,18 @@ class MoneySpin(QDoubleSpinBox): self.setValue(float(m.money(value))) -class QtySpin(QDoubleSpinBox): +class QtySpin(DecimalSpin): + """Количество. + + Три знака после запятой: товар может быть и штучным, и на розлив — + пол-литра сока это 0,5. + """ + def __init__(self, parent=None): super().__init__(parent) self.setDecimals(3) self.setRange(0, 1_000_000) self.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - # Дробные количества нужны для веса, но булки штучные — прячем нули. - self.setStepType(QDoubleSpinBox.DefaultStepType) def textFromValue(self, value: float) -> str: return m.fmt_qty(value) diff --git a/tests/test_ledger.py b/tests/test_ledger.py index a24d9d2..1f84122 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -420,6 +420,142 @@ def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks): assert report.summary.stock_qty == 0 +# --- Дробные количества: розлив и вес ------------------------------------ + + +@pytest.fixture +def juice(vault): + """Сок: берётся литрами, продаётся в том числе по пол-литра.""" + return journal.create_product(vault, "Сок", unit="л", cost_price=80, retail_price=120) + + +def test_fractional_sale_leaves_fractional_stock(vault, juice, today, in_two_weeks): + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}] + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)]) + + report = ledger.build(vault.doc, today) + batch = report.batches[0] + assert batch.qty_sold == Decimal("0.500") + assert batch.qty_left == Decimal("9.500") + assert batch.cash_collected == Decimal("60.00") # 0,5 × 120 + assert report.summary.gross_margin == Decimal("20.00") # 0,5 × (120 − 80) + + +def test_fractional_purchase(vault, juice, today, in_two_weeks): + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}] + ) + report = ledger.build(vault.doc, today) + assert report.batches[0].qty_total == Decimal("2.500") + assert report.batches[0].cost_total == Decimal("200.00") # 2,5 × 80 + + +def test_many_fractional_sales_add_up_exactly(vault, juice, today, in_two_weeks): + """Дробные не должны копить погрешность — потому и Decimal, а не float.""" + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}] + ) + for _ in range(20): + journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.1", 120)]) + + report = ledger.build(vault.doc, today) + assert report.batches[0].qty_sold == Decimal("2.000") + assert report.batches[0].qty_left == Decimal("8.000") + assert report.batches[0].cash_collected == Decimal("240.00") + + +def test_fractional_pack_size(vault, juice, today, in_two_weeks): + """Бутылка 1,5 л — фасовка дробного размера.""" + journal.set_packs(vault, juice.id, [{"name": "бутылка", "size": "1.5", "retail_price": 170}]) + journal.create_batch( + vault, today, in_two_weeks, + [{"product_id": juice.id, "qty": 4, "uom": "бутылка", "unit_cost": 120}], + ) + journal.create_sale( + vault, today, KIND_RETAIL, + [{"product_id": juice.id, "qty": 1, "uom": "бутылка", "unit_price": 170}], + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.5", 120)]) + + report = ledger.build(vault.doc, today) + batch = report.batches[0] + assert batch.qty_total == Decimal("6.000") # 4 бутылки × 1,5 л + assert batch.qty_sold == Decimal("2.000") # 1,5 + 0,5 + assert batch.qty_left == Decimal("4.000") + assert batch.cash_collected == Decimal("230.00") # 170 + 60 + + +def test_fractional_stock_value(vault, juice, today, in_two_weeks): + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 10, "unit_cost": 80}] + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.25", 120)]) + + summary = ledger.build(vault.doc, today).summary + assert summary.stock_qty == Decimal("9.750") + assert summary.stock_cost == Decimal("780.00") # 9,75 × 80 + + +def test_fractional_self_consumption(vault, juice, today, in_two_weeks): + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": juice.id, "qty": 5, "unit_cost": 80}] + ) + journal.create_sale(vault, today, KIND_SELF, [line(juice, "0.5", 0)]) + + summary = ledger.build(vault.doc, today).summary + assert summary.consumed_cost == Decimal("40.00") # 0,5 × 80 + assert summary.profit == Decimal("-40.00") + + +def test_fractional_sale_spills_across_batches(vault, juice, today): + journal.create_batch( + vault, today - timedelta(days=5), today + timedelta(days=9), + [{"product_id": juice.id, "qty": "1.5", "unit_cost": 80}], + ) + second = journal.create_batch( + vault, today, today + timedelta(days=14), + [{"product_id": juice.id, "qty": "2.5", "unit_cost": 80}], + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "2.25", 120)]) + + report = ledger.build(vault.doc, today) + assert report.batches[0].qty_sold == Decimal("1.500") + assert report.batch_report(second.id).qty_sold == Decimal("0.750") + assert report.batch_report(second.id).qty_left == Decimal("1.750") + + +def test_summary_counts_positions_because_units_differ(vault, juice, buns, today, in_two_weeks): + """Литры, килограммы и штуки в одну сумму не складываются. + + Сводка поэтому показывает деньги и число позиций, а не общее количество. + """ + cheese = journal.create_product(vault, "Сыр", unit="кг", cost_price=600, retail_price=900) + journal.create_batch( + vault, today, in_two_weeks, + [ + {"product_id": juice.id, "qty": 10, "unit_cost": 80}, + {"product_id": cheese.id, "qty": "3.5", "unit_cost": 600}, + buy(buns["мак"], 20, 20), + ], + ) + + summary = ledger.build(vault.doc, today).summary + assert summary.stock_positions == 3 + assert summary.stock_cost == Decimal("3300.00") # 800 + 2100 + 400 + + +def test_fractional_shortfall_is_reported(vault, juice, today, in_two_weeks): + journal.create_batch( + vault, today, in_two_weeks, [{"product_id": juice.id, "qty": "0.5", "unit_cost": 80}] + ) + journal.create_sale(vault, today, KIND_RETAIL, [line(juice, "0.75", 120)]) + + report = ledger.build(vault.doc, today) + assert [s.qty for s in report.shortfalls] == [Decimal("0.250")] + + def test_product_without_packs_behaves_exactly_as_before(vault, buns, today, in_two_weeks): journal.create_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)]) journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)]) diff --git a/tests/test_quick_sales.py b/tests/test_quick_sales.py index ad72e29..176db69 100644 --- a/tests/test_quick_sales.py +++ b/tests/test_quick_sales.py @@ -245,6 +245,27 @@ def test_same_person_twice_creates_one_counterparty(dialog, ctx, buns): assert len(ctx.vault.doc.counterparties) == 1 +def test_fractional_quantities_in_bulk_entry(ctx, qapp): + """Розлив вносится историей так же, как штучный товар.""" + juice = journal.create_product(ctx.vault, "Сок", unit="л", cost_price=80, retail_price=120) + d = QuickSalesDialog(ctx) + + for row, qty in enumerate(["0.5", "1.5", "0.25"]): + if row: + d.add_row() + fill(d, row, juice.id, qty) + + rows = d.collect() + assert [r["line"]["qty"] for r in rows] == [ + Decimal("0.500"), Decimal("1.500"), Decimal("0.250") + ] + assert "270,00 ₽" in d.total_label.text() # (0,5 + 1,5 + 0,25) × 120 + + write_sales(ctx.vault, rows) + assert sum(s.total for s in ctx.vault.doc.sales) == Decimal("270.00") + d.deleteLater() + + def test_partial_payment_becomes_a_debt(dialog, ctx, buns): fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140 write_sales(ctx.vault, dialog.collect()) diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 1d595cd..1375953 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -191,6 +191,9 @@ def test_dashboard_renders_metrics_and_deadline(window): assert "850,00 ₽" in texts # вся себестоимость партии ещё не возвращена assert "Мне должны" in texts assert "100,00 ₽" in texts # долг Васи + # Остаток показан деньгами и позициями, а не суммой разных единиц. + assert "610,00 ₽" in texts + assert "позиций: 3" in texts bars = page.body.findChildren(QProgressBar) assert len(bars) == 1 @@ -420,6 +423,104 @@ def test_lines_editor_add_and_remove(window, buns): dialog.deleteLater() +# --- дробные количества --------------------------------------------------- + + +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() + + page = window.pages[2][1] + 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) + + # --- настройки ------------------------------------------------------------