Compare commits

...

2 Commits

Author SHA1 Message Date
Claude
bc868f5af1 Git-операции всегда над папкой данных, а не над корнем приложения
Приложение создавало GitSync от paths.app_root(). Пока база лежала внутри
репозитория с кодом, это работало; после разделения репозиториев — нет.
exe лежит в корне клона с исходниками, поэтому приложение распоряжалось
репозиторием кода: переписало ему origin на food-records и попыталось
отправить туда исходники. Пуш спас только отказ по fast-forward.

Теперь GitSync создаётся в одном месте — MainWindow._git() — и всегда
от paths.data_dir(). Тест проверяет и цель, и то, что других мест
создания GitSync в файле не осталось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:30:42 +03:00
Claude
1a97fee872 Дробные количества: пол-литра сока, 1,125 кг сыра
Количества и раньше хранились в Decimal с тремя знаками, но до документа
они не доходили: поля ввода отвергали точку. Интерфейс русский, локаль
ждёт запятую, а на цифровой клавиатуре клавиша даёт точку — символ молча
не появлялся, и выглядело это так, будто дробные вводить нельзя вообще.
Теперь оба разделителя принимаются, пробелы-разделители разрядов тоже.

Заодно исправлена ошибка, которую дробные вскрыли: в сводке складывалось
общее количество остатка по всем товарам, то есть литры с килограммами
и штуками. Теперь остаток выражен деньгами и числом позиций, а разбивка
по единицам осталась в таблице «Остатки».

Фасовки тоже могут быть дробными: бутылка 1,5 л.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:20:29 +03:00
8 changed files with 350 additions and 12 deletions

View File

@ -32,6 +32,20 @@
- **Журнал** — что менялось в базе, когда и с какой машины. - **Журнал** — что менялось в базе, когда и с какой машины.
- **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн. - **Сводка** — долг пекарне, дебиторка, прибыль, остатки, ближайший дедлайн.
### Дробные количества
Количества хранятся с точностью до тысячных, поэтому 0,5 л сока, 1,125 кг сыра
или полбулки учитываются как есть. Единица измерения у товара произвольная —
`шт`, `л`, `кг`.
Поля ввода принимают и запятую, и точку: на цифровой клавиатуре точка, а
интерфейс русский и ждёт запятую, так что точка молча заменяется на запятую.
Без этого символ просто не появлялся бы в поле.
Общее количество остатка по всем товарам сразу нигде не показывается —
складывать литры с килограммами бессмысленно. В сводке остаток выражен деньгами
и числом позиций, а разбивка по товарам есть в таблице «Остатки».
### Фасовки ### Фасовки
Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — это **один Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — это **один

View File

@ -139,8 +139,12 @@ class Summary:
cash_collected: Decimal cash_collected: Decimal
gross_margin: Decimal gross_margin: Decimal
consumed_cost: Decimal consumed_cost: Decimal
# Осторожно: сумма по всем товарам сразу. Осмысленна, только когда единица
# измерения у всех одна. Литры с килограммами складывать нельзя, поэтому
# в сводке показывается не она, а stock_cost и stock_positions.
stock_qty: Decimal stock_qty: Decimal
stock_cost: Decimal stock_cost: Decimal
stock_positions: int
overdue_count: int overdue_count: int
next_due: BatchReport | None next_due: BatchReport | None
@ -476,6 +480,7 @@ def _summary(
consumed_cost=m.money(consumed), consumed_cost=m.money(consumed),
stock_qty=m.qty(sum((r.qty_left for r in batch_reports), m.ZERO)), 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_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), overdue_count=sum(1 for r in batch_reports if r.status == STATUS_OVERDUE),
next_due=next_due, next_due=next_due,
) )

View File

@ -168,6 +168,16 @@ class MainWindow(QMainWindow):
def sync_status_text(self) -> str: def sync_status_text(self) -> str:
return self._sync_text return self._sync_text
def _git(self) -> GitSync:
"""Git всегда работает над папкой данных, а не над корнем приложения.
Единственная точка создания GitSync: если exe лежит внутри клона
с исходниками, ошибка в этом месте заставила бы приложение
распоряжаться репозиторием кода переписать ему origin и запушить
исходники в репозиторий данных.
"""
return GitSync(paths.data_dir(), paths.vault_rel_posix(), self.vault.doc.settings.git)
# --- сохранение --- # --- сохранение ---
def _autosave(self) -> None: def _autosave(self) -> None:
@ -214,7 +224,7 @@ class MainWindow(QMainWindow):
self._sync_text = "синхронизация…" self._sync_text = "синхронизация…"
self._update_status() self._update_status()
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git) sync = self._git()
self._sync_task = SyncTask(sync, paths.remote_copy_path(), self) self._sync_task = SyncTask(sync, paths.remote_copy_path(), self)
self._sync_task.done.connect(lambda *args: self._on_sync_done(*args, quiet=quiet)) self._sync_task.done.connect(lambda *args: self._on_sync_done(*args, quiet=quiet))
self._sync_task.start() self._sync_task.start()
@ -267,8 +277,7 @@ class MainWindow(QMainWindow):
# --- разрешение конфликта --- # --- разрешение конфликта ---
def adopt_remote_version(self, _copy_path: Path) -> None: def adopt_remote_version(self, _copy_path: Path) -> None:
git = self.vault.doc.settings.git sync = self._git()
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git)
try: try:
sync.reset_to_remote() sync.reset_to_remote()
self.vault.reload() self.vault.reload()
@ -289,9 +298,8 @@ class MainWindow(QMainWindow):
self.changed() self.changed()
def force_push_local(self) -> None: def force_push_local(self) -> None:
git = self.vault.doc.settings.git
self.save_now() self.save_now()
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git) sync = self._git()
try: try:
sync.force_push() sync.force_push()
except GitError as exc: except GitError as exc:
@ -325,7 +333,7 @@ class MainWindow(QMainWindow):
# Последний пуш на выходе, с потолком по времени: висеть на закрытии # Последний пуш на выходе, с потолком по времени: висеть на закрытии
# из-за пропавшей сети приложение не должно. # из-за пропавшей сети приложение не должно.
self.status_label.setText("Отправляю базу на сервер…") self.status_label.setText("Отправляю базу на сервер…")
sync = GitSync(paths.app_root(), paths.vault_rel_posix(), git) sync = self._git()
task = SyncTask(sync, paths.remote_copy_path(), self) task = SyncTask(sync, paths.remote_copy_path(), self)
task.start() task.start()
task.wait(CLOSE_SYNC_WAIT_MS) task.wait(CLOSE_SYNC_WAIT_MS)

View File

@ -99,9 +99,12 @@ class DashboardPage(QWidget):
theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT, theme.OK if summary.profit > 0 else theme.DANGER if summary.profit < 0 else theme.TEXT,
), ),
( (
# Показываем деньги, а не количество: у сока литры, у сыра
# килограммы, у булок штуки — общая сумма штук бессмысленна.
# Разбивка по товарам есть ниже в «Остатках».
"Остаток на складе", "Остаток на складе",
m.fmt_qty(summary.stock_qty), m.fmt_money(summary.stock_cost, currency),
f"по себестоимости {m.fmt_money(summary.stock_cost, currency)}", f"позиций: {summary.stock_positions}" if summary.stock_positions else "пусто",
theme.TEXT, theme.TEXT,
), ),
] ]

View File

@ -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): def __init__(self, currency: str = "", parent=None):
super().__init__(parent) super().__init__(parent)
self.setDecimals(2) self.setDecimals(2)
@ -53,14 +73,18 @@ class MoneySpin(QDoubleSpinBox):
self.setValue(float(m.money(value))) self.setValue(float(m.money(value)))
class QtySpin(QDoubleSpinBox): class QtySpin(DecimalSpin):
"""Количество.
Три знака после запятой: товар может быть и штучным, и на розлив
пол-литра сока это 0,5.
"""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setDecimals(3) self.setDecimals(3)
self.setRange(0, 1_000_000) self.setRange(0, 1_000_000)
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
# Дробные количества нужны для веса, но булки штучные — прячем нули.
self.setStepType(QDoubleSpinBox.DefaultStepType)
def textFromValue(self, value: float) -> str: def textFromValue(self, value: float) -> str:
return m.fmt_qty(value) return m.fmt_qty(value)

View File

@ -420,6 +420,142 @@ def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
assert report.summary.stock_qty == 0 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): 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_batch(vault, today, in_two_weeks, [buy(buns["мак"], 10, 20)])
journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)]) journal.create_sale(vault, today, KIND_RETAIL, [line(buns["мак"], 4, 35)])

View File

@ -245,6 +245,27 @@ def test_same_person_twice_creates_one_counterparty(dialog, ctx, buns):
assert len(ctx.vault.doc.counterparties) == 1 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): def test_partial_payment_becomes_a_debt(dialog, ctx, buns):
fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140 fill(dialog, 0, buns["мак"].id, 4, paid=100) # всего 140
write_sales(ctx.vault, dialog.collect()) write_sales(ctx.vault, dialog.collect())

View File

@ -107,6 +107,32 @@ def test_sync_is_skipped_without_a_remote(window):
assert window._syncing is False assert window._syncing is False
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
# --- таблицы наполняются -------------------------------------------------- # --- таблицы наполняются --------------------------------------------------
@ -191,6 +217,9 @@ def test_dashboard_renders_metrics_and_deadline(window):
assert "850,00 ₽" in texts # вся себестоимость партии ещё не возвращена assert "850,00 ₽" in texts # вся себестоимость партии ещё не возвращена
assert "Мне должны" in texts assert "Мне должны" in texts
assert "100,00 ₽" in texts # долг Васи assert "100,00 ₽" in texts # долг Васи
# Остаток показан деньгами и позициями, а не суммой разных единиц.
assert "610,00 ₽" in texts
assert "позиций: 3" in texts
bars = page.body.findChildren(QProgressBar) bars = page.body.findChildren(QProgressBar)
assert len(bars) == 1 assert len(bars) == 1
@ -420,6 +449,104 @@ def test_lines_editor_add_and_remove(window, buns):
dialog.deleteLater() 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)
# --- настройки ------------------------------------------------------------ # --- настройки ------------------------------------------------------------