From d08d2421f7f7e6688b39b0e81f555953e35849a4 Mon Sep 17 00:00:00 2001 From: food-market Date: Tue, 25 Aug 2026 00:25:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=BB=D0=B3=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80,=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=83=20=D1=84=D0=B0=D1=81=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Долг было непонятно как записать, и не зря. Оплата была просто денежным полем с полной суммой: долг получался, если догадаться стереть число и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что оплачено полностью, а любая правка строк подставляла полную сумму обратно и молча стирала выставленный долг. Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил часть. Пересчёт работает только в первом режиме. Поле суммы осталось живым — вписанное руками число само зажигает подходящий переключатель. Долг без имени записать нельзя: на экране долгов такая запись попадает в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром вводе это предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это был. На экране долгов человека можно выбрать целиком и рассчитаться сразу за всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых. Цены фасовок переехали в «Цены и история». Они меняются одним решением поставщика вместе с ценой товара, а история была только у товара — у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке товара цены фасовок теперь только показываются, правится там состав. Co-Authored-By: Claude Opus 5 --- README.md | 46 ++++++++++ app/journal.py | 148 ++++++++++++++++++++++++++++-- app/models.py | 40 +++++++- app/storage.py | 16 ++++ app/ui/page_debts.py | 85 ++++++++++++++--- app/ui/page_products.py | 147 ++++++++++++++++++++++++++++-- app/ui/page_sales.py | 196 +++++++++++++++++++++++++++++++++++----- app/ui/quick_sales.py | 32 +++++++ tests/test_ledger.py | 110 ++++++++++++++++++++++ tests/test_ui_smoke.py | 146 +++++++++++++++++++++++++++++- 10 files changed, 913 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 773426e..b64586b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,39 @@ корзин больше 24, на графике показываются последние, и об этом написано прямо над ним — молча показанная часть выглядела бы как всё. +### Взял в долг + +В форме продажи выбор рассчёта явный, тремя переключателями: + +| | | +|---|---| +| **Заплатил полностью** | по умолчанию, сумма подставляется сама | +| **Взял в долг целиком** | получено ноль, вся сумма уходит в долг | +| **Заплатил часть** | сколько дал — в поле, остаток в долг | + +Раньше это было просто денежное поле, заполненное полной суммой: долг +получался, если догадаться стереть число и вписать ноль. Хуже того, любая +последующая правка строк подставляла полную оплату обратно — выставленный долг +исчезал молча, а подсказка продолжала уверять, что оплачено полностью. +Теперь пересчёт работает только в режиме «заплатил полностью». + +Поле суммы остаётся живым: вписал число руками — загорится подходящий +переключатель. Ноль включает «в долг целиком», часть суммы — «заплатил часть», +больше суммы — разница уходит в чаевые. + +**Долг без имени записать нельзя.** Такая запись невосстановима: на экране +долгов она попадает в кучу «без контрагента», и с кого спрашивать деньги — +уже не узнать. Форма продажи не выпустит такую запись и подсветит подсказку; +контрагента можно завести кнопкой рядом с полем «Кому». В быстром вводе это +предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это +был. + +На экране долгов можно выбрать **человека целиком** и рассчитаться сразу за +всё: деньги приходят одной суммой, а гасятся долги по очереди, начиная со +старых. Отдал больше, чем был должен, — излишек становится чаевыми, как и +переплата по одной продаже. Выбранная отдельная продажа по-прежнему гасится +сама по себе. + ### Отбор продаж Список продаж растёт быстрее всех остальных, поэтому у него своя панель @@ -196,6 +229,19 @@ так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры в старых базах не поехали. +**Цены фасовок меняются там же, где цены товара** — кнопкой «Цены и история», +и попадают в ту же историю. Дорожают они обычно одним решением поставщика, и +разводить их по разным окнам значило бы, что у товара история есть, а у коробки +нет. В карточке товара (кнопка «Изменить») цены фасовок показаны, но не +редактируются: иначе цена коробки менялась бы из двух мест, и из одного — молча. +Там правится только состав: название и размер. + +Стартовая цена новой фасовки тоже попадает в историю — отдельной точкой +«заведена фасовка «коробка»» либо, если товар только что создан, прямо в его +точку создания. Приписывать коробке цену задним числом программа не станет: +у снимков, сделанных до её появления, фасовок нет, и пустое место честнее +выдуманных цифр. + Если один товар попал в партию и коробками, и поштучно, себестоимость базовой единицы становится средневзвешенной: остаток по товару всё равно один, и при поштучной продаже иначе было бы непонятно, какая из двух цен списывается. diff --git a/app/journal.py b/app/journal.py index d0b0f47..c64e0e4 100644 --- a/app/journal.py +++ b/app/journal.py @@ -35,6 +35,7 @@ from .models import ( Counterparty, JournalEntry, Pack, + PackPrice, Payment, PricePoint, Product, @@ -268,6 +269,13 @@ def update_product( return product +def _snapshot_packs(product: Product) -> list[PackPrice]: + return [ + PackPrice(name=p.name, cost_price=p.cost_price, retail_price=p.retail_price) + for p in product.packs + ] + + def change_prices( vault, product_id: str, @@ -275,8 +283,17 @@ def change_prices( retail_price, on_date: date | None = None, note: str = "", + packs: list[dict] | None = None, ) -> Product: - """Поменять цены товара, оставив след в истории цен.""" + """Поменять цены товара и его фасовок, оставив след в истории. + + Цены фасовок меняются только здесь. Раньше они правились в карточке + товара и уезжали молча: у товара история была, у коробки — нет, хотя + дорожают они обычно одновременно и одним решением. + + packs=None означает «фасовки не трогаем» — их цены всё равно попадут + в снимок, чтобы история была полной. + """ doc = vault.doc product = doc.product(product_id) if product is None: @@ -286,18 +303,45 @@ def change_prices( retail_price = m.money(retail_price) if cost_price < 0 or retail_price < 0: raise ValidationError("Цена не может быть отрицательной.") - if cost_price == product.cost_price and retail_price == product.retail_price: + + wanted: dict[str, tuple[Decimal, Decimal]] = {} + for raw in packs or []: + name = (raw.get("name") or "").strip() + pack = product.pack(name) + if pack is None: + continue + pack_cost = m.money(raw.get("cost_price")) + pack_retail = m.money(raw.get("retail_price")) + if pack_cost < 0 or pack_retail < 0: + raise ValidationError("Цена фасовки не может быть отрицательной.") + wanted[name] = (pack_cost, pack_retail) + + unchanged = ( + cost_price == product.cost_price + and retail_price == product.retail_price + and all( + product.pack(name).cost_price == c and product.pack(name).retail_price == r + for name, (c, r) in wanted.items() + ) + ) + if unchanged: return product before = _flat_product(doc, product) product.cost_price = cost_price product.retail_price = retail_price + for name, (pack_cost, pack_retail) in wanted.items(): + pack = product.pack(name) + pack.cost_price = pack_cost + pack.retail_price = pack_retail + product.price_history.append( PricePoint( date=on_date or date.today(), cost_price=cost_price, retail_price=retail_price, note=note.strip(), + packs=_snapshot_packs(product), ) ) _record( @@ -312,7 +356,14 @@ def change_prices( def set_packs(vault, product_id: str, packs: list[dict]) -> Product: - """Задать фасовки товара. + """Задать состав фасовок: названия и размеры. + + Цены существующих фасовок здесь не меняются — для них есть change_prices, + который пишет историю. Иначе цена коробки правилась бы из двух мест, и из + одного из них она уезжала бы молча. + + Цены берутся только у новых фасовок: их первое значение — это заведение, + а не изменение, и истории до него нет. Уже записанные документы помнят размер фасовки слепком, поэтому правка здесь не пересчитывает историю — меняется только то, что подставится @@ -339,20 +390,58 @@ def set_packs(vault, product_id: str, packs: list[dict]) -> Product: # Обе цены — за фасовку целиком. Ноль значит «своей цены нет, # считай от цены базовой единицы». - retail = m.money(raw.get("retail_price")) - cost = m.money(raw.get("cost_price")) - if retail < 0 or cost < 0: - raise ValidationError("Цена фасовки не может быть отрицательной.") + existing = product.pack(name) + if existing is not None: + retail, cost = existing.retail_price, existing.cost_price + else: + retail = m.money(raw.get("retail_price")) + cost = m.money(raw.get("cost_price")) + if retail < 0 or cost < 0: + raise ValidationError("Цена фасовки не может быть отрицательной.") built.append(Pack(name=name, size=size, retail_price=retail, cost_price=cost)) before = _flat_product(doc, product) + known = {p.name for p in product.packs} product.packs = built + + # Заведённая фасовка со своей ценой — тоже точка истории. Иначе у коробки + # история начиналась бы только со второй цены, а первая, с которой её + # закупали, нигде бы не значилась. + fresh = [p for p in built if p.name not in known and (p.cost_price > 0 or p.retail_price > 0)] + if fresh: + _remember_pack_prices(product, fresh) + changes = _diff(before, _flat_product(doc, product)) if changes: _record(vault, "product.packs", "product", product.id, product.name, changes) return product +def _remember_pack_prices(product: Product, fresh: list[Pack]) -> None: + """Записать стартовые цены новых фасовок в историю. + + Товар только что создан — дописываем их в его же точку создания: заводить + рядом вторую тем же днём было бы шумом. Условие намеренно узкое (история + состоит ровно из одной точки): в любой другой ситуации дописать цену + в существующий снимок значило бы приписать коробке цену задним числом. + """ + today = date.today() + if len(product.price_history) == 1 and not product.price_history[0].packs: + product.price_history[0].packs = _snapshot_packs(product) + return + + names = ", ".join(f"«{p.name}»" for p in fresh) + product.price_history.append( + PricePoint( + date=today, + cost_price=product.cost_price, + retail_price=product.retail_price, + note=f"заведена фасовка {names}" if len(fresh) == 1 else f"заведены фасовки {names}", + packs=_snapshot_packs(product), + ) + ) + + def delete_product(vault, product_id: str) -> None: """Удалить товар, если он нигде не использован. @@ -776,6 +865,51 @@ def add_sale_payment(vault, sale_id: str, amount, on_date: date | None = None, n return sale +def pay_off_counterparty( + vault, + counterparty_id: str | None, + amount, + on_date: date | None = None, + note: str = "", +) -> list[tuple[Sale, Decimal]]: + """Принять деньги от человека сразу по всем его долгам, со старых. + + Вася отдаёт 500 — этого может хватить на три продажи разом. Разносить их + по одной руками бессмысленно: деньги пришли одной суммой, а какой долг + гасится первым, определяет очередь, а не выбор. + + Излишек сверх всех долгов не теряется — он становится чаевыми на самой + свежей продаже, так же как переплата по одной. + """ + doc = vault.doc + amount = m.money(amount) + if amount <= 0: + raise ValidationError("Сумма оплаты должна быть больше нуля.") + + debts = sorted( + (s for s in doc.sales if s.counterparty_id == counterparty_id and s.debt > 0), + key=lambda s: (s.date, doc.sales.index(s)), + ) + if not debts: + raise ValidationError("За этим человеком долгов нет.") + + applied: list[tuple[Sale, Decimal]] = [] + left = amount + for sale in debts: + if left <= 0: + break + chunk = min(sale.debt, left) + add_sale_payment(vault, sale.id, chunk, on_date, note) + applied.append((sale, chunk)) + left -= chunk + + # Отдал больше, чем был должен: сдачу не берём — это чаевые. + if left > 0: + add_sale_payment(vault, debts[-1].id, left, on_date, note) + applied.append((debts[-1], left)) + return applied + + def set_sale_tip(vault, sale_id: str, amount) -> Sale: """Задать чаевые по уже записанной продаже.""" doc = vault.doc diff --git a/app/models.py b/app/models.py index a212350..301eb92 100644 --- a/app/models.py +++ b/app/models.py @@ -15,7 +15,7 @@ from typing import Any from . import money as m -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 ONE = Decimal(1) @@ -65,18 +65,52 @@ def dump_date(value: date) -> str: # --- Номенклатура --------------------------------------------------------- +@dataclass +class PackPrice: + """Цены одной фасовки в момент снимка истории. + + Хранится копией, а не ссылкой на фасовку: фасовку могут переименовать или + удалить, а история обязана остаться читаемой. + """ + + name: str + cost_price: Decimal = m.ZERO + retail_price: Decimal = m.ZERO + + @classmethod + def from_dict(cls, d: dict) -> "PackPrice": + return cls( + name=d.get("name", ""), + cost_price=m.money(d.get("cost_price")), + retail_price=m.money(d.get("retail_price")), + ) + + def to_dict(self) -> dict: + return { + "name": self.name, + "cost_price": m.dumps(self.cost_price), + "retail_price": m.dumps(self.retail_price), + } + + @dataclass class PricePoint: """Снимок цен товара на момент изменения. Пишется при каждой правке цены, поэтому история цен видна прямо в карточке - товара, не полагаясь на историю git. + товара, не полагаясь на историю git. Цены фасовок входят в тот же снимок: + иначе у товара история была бы, а у коробки — нет, хотя дорожают они + обычно вместе. """ date: date cost_price: Decimal retail_price: Decimal note: str = "" + packs: list[PackPrice] = field(default_factory=list) + + def pack(self, name: str) -> "PackPrice | None": + return next((p for p in self.packs if p.name == name), None) @classmethod def from_dict(cls, d: dict) -> "PricePoint": @@ -85,6 +119,7 @@ class PricePoint: cost_price=m.money(d.get("cost_price")), retail_price=m.money(d.get("retail_price")), note=d.get("note", ""), + packs=[PackPrice.from_dict(p) for p in d.get("packs", [])], ) def to_dict(self) -> dict: @@ -93,6 +128,7 @@ class PricePoint: "cost_price": m.dumps(self.cost_price), "retail_price": m.dumps(self.retail_price), "note": self.note, + "packs": [p.to_dict() for p in self.packs], } diff --git a/app/storage.py b/app/storage.py index 57024fb..9f0340e 100644 --- a/app/storage.py +++ b/app/storage.py @@ -109,12 +109,28 @@ def _migrate_4_to_5(raw: dict) -> dict: return raw +def _migrate_5_to_6(raw: dict) -> dict: + """Цены фасовок вошли в историю цен товара. + + У снимков, записанных раньше, фасовок в истории нет — и придумывать их + задним числом нельзя: тогдашние цены фасовок никто не сохранял. + Пустой список честнее выдуманных цифр. + """ + for product in raw.get("products", []): + for point in product.get("price_history", []): + point.setdefault("packs", []) + + raw["schema_version"] = 6 + return raw + + _MIGRATIONS = { 0: _migrate_0_to_1, 1: _migrate_1_to_2, 2: _migrate_2_to_3, 3: _migrate_3_to_4, 4: _migrate_4_to_5, + 5: _migrate_5_to_6, } diff --git a/app/ui/page_debts.py b/app/ui/page_debts.py index 767ebad..f384c18 100644 --- a/app/ui/page_debts.py +++ b/app/ui/page_debts.py @@ -21,14 +21,16 @@ from PySide6.QtWidgets import ( from .. import journal from .. import money as m from ..models import SALE_KIND_LABELS +from ..salesfilter import plural from . import theme from . import widgets as w ROLE_SALE_ID = Qt.UserRole + 2 +ROLE_COUNTERPARTY = Qt.UserRole + 3 class PaymentDialog(QDialog): - def __init__(self, suggested: Decimal, currency: str, parent=None): + def __init__(self, suggested: Decimal, currency: str, parent=None, what: str = "этой продаже"): super().__init__(parent) self.setWindowTitle("Принять оплату") self.setMinimumWidth(340) @@ -52,16 +54,18 @@ class PaymentDialog(QDialog): form.addRow("Заметка", self.note) layout.addLayout(form) - layout.addWidget(w.label(f"Долг по этой продаже: {m.fmt_money(suggested, currency)}", "dim")) + self.hint = w.label(f"Долг по {what}: {m.fmt_money(suggested, currency)}", "dim") + self.hint.setWordWrap(True) + layout.addWidget(self.hint) layout.addWidget( w.row(None, w.button("Отмена", on_click=self.reject), w.button("Принять", "primary", self.accept)) ) self.amount.setFocus() -def ask_payment(parent, suggested: Decimal, currency: str) -> Decimal | None: +def ask_payment(parent, suggested: Decimal, currency: str, what: str = "этой продаже") -> Decimal | None: """Спросить сумму оплаты. Возвращает None, если отменили.""" - dialog = PaymentDialog(suggested, currency, parent) + dialog = PaymentDialog(suggested, currency, parent, what) if dialog.exec() != QDialog.Accepted: return None return dialog.amount.value_decimal() @@ -88,6 +92,13 @@ class DebtsPage(QWidget): self.total_label, ) ) + layout.addWidget( + w.label( + "Выбери человека — рассчитаешься сразу за все его долги, начиная " + "со старых. Выбери отдельную продажу — оплата пойдёт только за неё.", + "dim", + ) + ) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Кто / что", "Дата", "Сумма", "Оплачено", "Долг"]) @@ -119,6 +130,9 @@ class DebtsPage(QWidget): parent.setFont(4, font) parent.setForeground(4, QColor(theme.WARN)) parent.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter) + # Человека можно выбрать целиком и рассчитаться сразу за всё: + # деньги приходят одной суммой, а не по продаже за раз. + parent.setData(0, ROLE_COUNTERPARTY, debtor.counterparty_id) for sale in sorted(debtor.sales, key=lambda s: s.date): what = ", ".join( @@ -153,21 +167,36 @@ class DebtsPage(QWidget): # --- действия --- + def _selected_debtor(self): + """Выбранный человек и его общий долг — либо None, если выбрана продажа.""" + item = self.tree.currentItem() + if item is None or item.parent() is not None: + return None + cp_id = item.data(0, ROLE_COUNTERPARTY) + return next( + (d for d in self.ctx.report.debtors if d.counterparty_id == cp_id), None + ) + def _selected_sale(self): item = self.tree.currentItem() if item is None: - QMessageBox.information(self, "Не выбрано", "Выбери конкретную продажу.") + QMessageBox.information(self, "Не выбрано", "Выбери человека или продажу.") return None sale_id = item.data(0, ROLE_SALE_ID) - if sale_id is None: + if not sale_id: QMessageBox.information( - self, "Выбери продажу", "Раскрой человека и выбери, за что он платит." + self, "Не выбрано", "Выбери человека или конкретную продажу." ) return None return self.ctx.vault.doc.sale(sale_id) def take_payment(self) -> None: + debtor = self._selected_debtor() + if debtor is not None: + self._pay_debtor(debtor) + return + sale = self._selected_sale() if sale is None: return @@ -182,11 +211,45 @@ class DebtsPage(QWidget): return self.ctx.changed() - def settle_full(self) -> None: - sale = self._selected_sale() - if sale is None: + def _pay_debtor(self, debtor) -> None: + """Принять деньги от человека сразу за всё, что за ним числится.""" + amount = ask_payment( + self, + debtor.debt, + self.ctx.currency, + what=f"{debtor.name} — {len(debtor.sales)} " + + plural(len(debtor.sales), "продажа", "продажи", "продаж"), + ) + if amount is None: return - if sale.debt <= 0: + try: + applied = journal.pay_off_counterparty( + self.ctx.vault, debtor.counterparty_id, amount + ) + except journal.ValidationError as exc: + QMessageBox.warning(self, "Не получилось", str(exc)) + return + + self.ctx.changed() + if len(applied) > 1: + QMessageBox.information( + self, + "Готово", + f"{m.fmt_money(amount, self.ctx.currency)} разнесено " + f"по продажам: {len(applied)}, начиная со старых.", + ) + + def settle_full(self) -> None: + debtor = self._selected_debtor() + if debtor is not None: + journal.pay_off_counterparty( + self.ctx.vault, debtor.counterparty_id, debtor.debt + ) + self.ctx.changed() + return + + sale = self._selected_sale() + if sale is None or sale.debt <= 0: return journal.add_sale_payment(self.ctx.vault, sale.id, sale.debt) self.ctx.changed() diff --git a/app/ui/page_products.py b/app/ui/page_products.py index 16d31e1..caa4309 100644 --- a/app/ui/page_products.py +++ b/app/ui/page_products.py @@ -73,11 +73,16 @@ class PacksEditor(QWidget): packs=(), cost_source=None, retail_source=None, + lock_prices: bool = False, parent=None, ): super().__init__(parent) self.currency = currency self.base_unit = base_unit + # У заведённой фасовки цена правится только через «Цены и история» — + # там она попадает в историю. Здесь её показываем, но не даём трогать, + # иначе цена коробки менялась бы из двух мест и из одного молча. + self.lock_prices = lock_prices # Откуда брать цены базовой единицы: по ним считается фасовка, # у которой своей цены нет. self.cost_source = cost_source or (lambda: m.ZERO) @@ -117,7 +122,9 @@ class PacksEditor(QWidget): layout.addWidget(self.derived) for pack in packs: - self.add_pack(pack.name, pack.size, pack.retail_price, pack.cost_price) + self.add_pack( + pack.name, pack.size, pack.retail_price, pack.cost_price, locked=lock_prices + ) self.refresh_hint() def refresh_hint(self) -> None: @@ -139,7 +146,10 @@ class PacksEditor(QWidget): ) for pack in packs ] - self.derived.setText("В документы подставится:\n" + "\n".join(lines)) + text = "В документы подставится:\n" + "\n".join(lines) + if self.lock_prices: + text += "\n\nЦены фасовок меняются кнопкой «Цены и история» — так у них остаётся история." + self.derived.setText(text) def set_base_unit(self, unit: str) -> None: self.base_unit = unit or "шт" @@ -148,7 +158,7 @@ class PacksEditor(QWidget): ) self.refresh_hint() - def add_pack(self, name: str = "", size=1, price=0, cost=0) -> None: + def add_pack(self, name: str = "", size=1, price=0, cost=0, locked: bool = False) -> None: r = self.table.rowCount() self.table.insertRow(r) @@ -173,6 +183,12 @@ class PacksEditor(QWidget): price_spin.set_decimal(price) price_spin.valueChanged.connect(self.refresh_hint) self.table.setCellWidget(r, self.COL_RETAIL, price_spin) + + if locked: + for spin in (cost_spin, price_spin): + spin.setReadOnly(True) + spin.setButtonSymbols(w.MoneySpin.NoButtons) + spin.setToolTip("Цены фасовок меняются кнопкой «Цены и история».") self.refresh_hint() def remove_current(self) -> None: @@ -252,6 +268,7 @@ class ProductDialog(QDialog): # берём их прямо из полей, чтобы подсказка считалась на лету. cost_source=(lambda: product.cost_price) if product else self.cost.value_decimal, retail_source=(lambda: product.retail_price) if product else self.retail.value_decimal, + lock_prices=product is not None, ) self.unit.textChanged.connect(self.packs.set_base_unit) if product is None: @@ -277,14 +294,107 @@ class ProductDialog(QDialog): ) +def history_packs_text(point, currency: str) -> str: + """Цены фасовок из снимка истории одной строкой.""" + if not point.packs: + return "" + return "; ".join( + f"{p.name} {m.fmt_money(p.cost_price, currency)} → {m.fmt_money(p.retail_price, currency)}" + if p.cost_price > 0 or p.retail_price > 0 + else f"{p.name} от цены товара" + for p in point.packs + ) + + +class PackPricesEditor(QWidget): + """Цены фасовок внутри диалога цен — там же, где история. + + Раньше они правились в карточке товара и уезжали молча: у товара история + была, у коробки — нет, хотя дорожают они обычно одним решением. + """ + + COL_NAME, COL_COST, COL_RETAIL = range(3) + + def __init__(self, currency: str, product, parent=None): + super().__init__(parent) + self.currency = currency + self.product = product + # Откуда брать цены базовой единицы: фасовка без своей цены считается + # от них. Диалог подменяет их своими полями сразу после создания. + self.parent_cost = lambda: product.cost_price + self.parent_retail = lambda: product.retail_price + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.table = QTableWidget(len(product.packs), 3) + self.table.setHorizontalHeaderLabels( + ["Фасовка", "Закупка за фасовку", "Продажа за фасовку"] + ) + self.table.verticalHeader().setVisible(False) + self.table.verticalHeader().setDefaultSectionSize(36) + header = self.table.horizontalHeader() + header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch) + for column in (self.COL_COST, self.COL_RETAIL): + header.setSectionResizeMode(column, QHeaderView.Interactive) + self.table.setColumnWidth(column, 155) + self.table.setMaximumHeight(36 * min(4, max(1, len(product.packs))) + 30) + layout.addWidget(self.table) + + for r, pack in enumerate(product.packs): + item = w.text_item(f"{pack.name} = {m.fmt_qty(pack.size)} {product.unit}") + self.table.setItem(r, self.COL_NAME, item) + for column, value in ( + (self.COL_COST, pack.cost_price), + (self.COL_RETAIL, pack.retail_price), + ): + spin = w.MoneySpin(currency) + spin.set_decimal(value) + spin.valueChanged.connect(self.refresh_hint) + self.table.setCellWidget(r, column, spin) + + self.derived = w.label("", "dim") + self.derived.setWordWrap(True) + layout.addWidget(self.derived) + + def refresh_hint(self) -> None: + lines = [ + pack_hint( + { + "name": self.product.packs[r].name, + "size": self.product.packs[r].size, + "cost_price": self.table.cellWidget(r, self.COL_COST).value_decimal(), + "retail_price": self.table.cellWidget(r, self.COL_RETAIL).value_decimal(), + }, + self.product.unit, + self.parent_cost(), + self.parent_retail(), + self.currency, + ) + for r in range(self.table.rowCount()) + ] + self.derived.setText("\n".join(lines)) + + def packs(self) -> list[dict]: + return [ + { + "name": self.product.packs[r].name, + "cost_price": self.table.cellWidget(r, self.COL_COST).value_decimal(), + "retail_price": self.table.cellWidget(r, self.COL_RETAIL).value_decimal(), + } + for r in range(self.table.rowCount()) + ] + + class PriceDialog(QDialog): - """Смена цен с записью в историю.""" + """Смена цен с записью в историю — и товара, и его фасовок.""" def __init__(self, currency: str, product, parent=None): super().__init__(parent) self.product = product self.setWindowTitle(f"Цены — {product.name}") - self.setMinimumWidth(520) + self.setMinimumWidth(660) layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) @@ -295,11 +405,11 @@ class PriceDialog(QDialog): self.cost = w.MoneySpin(currency) self.cost.set_decimal(product.cost_price) - form.addRow("Себестоимость", self.cost) + form.addRow(f"Себестоимость за {product.unit}", self.cost) self.retail = w.MoneySpin(currency) self.retail.set_decimal(product.retail_price) - form.addRow("Цена продажи", self.retail) + form.addRow(f"Цена продажи за {product.unit}", self.retail) self.date = w.DateInput() form.addRow("С даты", self.date) @@ -309,9 +419,25 @@ class PriceDialog(QDialog): form.addRow("Причина", self.note) layout.addLayout(form) + + self.packs = None + if product.packs: + layout.addWidget(w.label("Цены фасовок", "h2")) + self.packs = PackPricesEditor(currency, product) + # Фасовка без своей цены считается от цены товара, поэтому + # расшифровка должна пересчитываться и при правке полей выше. + self.packs.parent_cost = self.cost.value_decimal + self.packs.parent_retail = self.retail.value_decimal + for field in (self.cost, self.retail): + field.valueChanged.connect(lambda _: self.packs.refresh_hint()) + self.packs.refresh_hint() + layout.addWidget(self.packs) + layout.addWidget(w.label("История цен", "h2")) - history = w.table(["Дата", "Себестоимость", "Цена продажи", "Причина"], stretch_column=3) + history = w.table( + ["Дата", "Себестоимость", "Цена продажи", "Фасовки", "Причина"], stretch_column=4 + ) history.setSortingEnabled(False) history.setMaximumHeight(220) rows = [ @@ -319,6 +445,7 @@ class PriceDialog(QDialog): w.text_item(point.date.strftime("%d.%m.%Y")), w.num_item(m.fmt_money(point.cost_price, currency), point.cost_price), w.num_item(m.fmt_money(point.retail_price, currency), point.retail_price), + w.text_item(history_packs_text(point, currency), theme.TEXT_DIM), w.text_item(point.note), ] for point in reversed(product.price_history) @@ -330,6 +457,9 @@ class PriceDialog(QDialog): w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept)) ) + def pack_prices(self) -> list[dict] | None: + return self.packs.packs() if self.packs is not None else None + class ProductsPage(QWidget): def __init__(self, ctx, parent=None): @@ -478,6 +608,7 @@ class ProductsPage(QWidget): dialog.retail.value_decimal(), dialog.date.get_date(), dialog.note.text(), + packs=dialog.pack_prices(), ) except journal.ValidationError as exc: QMessageBox.warning(self, "Не получилось", str(exc)) diff --git a/app/ui/page_sales.py b/app/ui/page_sales.py index 339eada..7a7be6e 100644 --- a/app/ui/page_sales.py +++ b/app/ui/page_sales.py @@ -4,11 +4,13 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QButtonGroup, QComboBox, QDialog, QFormLayout, QLineEdit, QMessageBox, + QRadioButton, QVBoxLayout, QWidget, ) @@ -27,6 +29,37 @@ from . import theme from . import widgets as w +# --- как рассчитались за продажу ----------------------------------------- +# +# Раньше это был просто денежный ввод, заполненный полной суммой. Долг из него +# получался, если догадаться стереть число и вписать ноль, — а потом любая +# правка строк молча возвращала полную оплату обратно. Явный выбор режима +# делает «взял в долг» видимым вариантом, а не побочным эффектом. + +PAY_FULL = "full" +PAY_DEBT = "debt" +PAY_PART = "part" + +PAY_LABELS = { + PAY_FULL: "Заплатил полностью", + PAY_DEBT: "Взял в долг целиком", + PAY_PART: "Заплатил часть", +} + + +def pay_mode_for(total, paid): + """Каким режимом описывается эта пара сумм. + + Нужно, чтобы переключатель отражал набранное вручную: вписал ноль — + загорается «взял в долг», вписал всю сумму — «заплатил полностью». + """ + if paid <= 0: + return PAY_DEBT + if paid >= total: + return PAY_FULL + return PAY_PART + + def price_source_for(kind: str): """Откуда берётся цена по умолчанию для этого типа выбытия и фасовки. @@ -74,6 +107,8 @@ class SaleDialog(QDialog): index = self.counterparty.findData(sale.counterparty_id) if index >= 0: self.counterparty.setCurrentIndex(index) + # Выбор человека снимает предупреждение о долге без имени. + self.counterparty.currentIndexChanged.connect(lambda _: self._update_payment_hint()) form.addRow("Кому", w.row(self.counterparty, w.button("+ Новый", on_click=self._add_counterparty), stretch_at=0)) self.note = QLineEdit(sale.note if sale else "") @@ -87,6 +122,8 @@ class SaleDialog(QDialog): self.paid = None self.tip = None self.paid_hint = None + self.pay_mode = None + self.pay_buttons = {} layout.addWidget(w.label("Что ушло", "h2")) products = [p for p in ctx.vault.doc.products if not p.archived] @@ -104,11 +141,16 @@ class SaleDialog(QDialog): # живут своей жизнью и правятся на экране долгов. pay_form = QFormLayout() if sale is None: + pay_form.addRow("Как рассчитались", self._pay_mode_row()) + self.paid = w.MoneySpin(ctx.currency) # Переплату разбираем, когда человек закончил вводить сумму, # а не на каждом нажатии: иначе поле дёргалось бы посреди набора. - self.paid.editingFinished.connect(self._absorb_overpayment) - pay_form.addRow("Оплачено сразу", self.paid) + self.paid.editingFinished.connect(self._on_paid_edited) + # Без этого поле можно было обнулить, а подсказка продолжала + # уверять, что оплачено полностью. + self.paid.valueChanged.connect(lambda _: self._update_payment_hint()) + pay_form.addRow("Получено сразу", self.paid) # Чаевые правятся и у записанной продажи: сдачу могли оставить # и после того, как продажу занесли. @@ -128,6 +170,82 @@ class SaleDialog(QDialog): ) self._on_kind_changed() + # --- как рассчитались --- + + def _pay_mode_row(self) -> QWidget: + self.pay_mode = PAY_FULL + self.pay_group = QButtonGroup(self) + buttons = [] + for mode in (PAY_FULL, PAY_DEBT, PAY_PART): + button = QRadioButton(PAY_LABELS[mode]) + button.setChecked(mode == PAY_FULL) + button.toggled.connect( + lambda checked, value=mode: checked and self._set_pay_mode(value) + ) + self.pay_group.addButton(button) + self.pay_buttons[mode] = button + buttons.append(button) + return w.row(*buttons, None) + + def _set_pay_mode(self, mode: str) -> None: + """Переключили режим руками — подставить сумму под него.""" + self.pay_mode = mode + if self.paid is None: + return + + if mode == PAY_FULL: + self.paid.set_decimal(self.lines.total()) + elif mode == PAY_DEBT: + self.paid.set_decimal(0) + else: + self.paid.setFocus() + self.paid.selectAll() + self._update_payment_hint() + + def _show_pay_mode(self, mode: str) -> None: + """Отразить режим в переключателе, не трогая суммы.""" + self.pay_mode = mode + button = self.pay_buttons.get(mode) + if button is None or button.isChecked(): + return + button.blockSignals(True) + button.setChecked(True) + button.blockSignals(False) + + def _on_paid_edited(self) -> None: + """Сумму вписали руками: разобрать переплату и показать, что вышло.""" + overpaid = self._absorb_overpayment() + if self.paid is not None: + self._show_pay_mode(pay_mode_for(self.lines.total(), self.paid.value_decimal())) + self._update_payment_hint(overpaid) + + def problem(self) -> str | None: + """Что мешает записать продажу. None — всё в порядке. + + Отдельным методом, а не прямо в accept: проверка — это правило, и его + должно быть видно тесту, а не только человеку в модальном окне. + """ + if self.current_debt() > 0 and not self.counterparty.currentData(): + return ( + f"В долг уходит {m.fmt_money(self.current_debt(), self.ctx.currency)}, " + "но не указано кому.\n\nВыбери человека в поле «Кому» или заведи " + "нового кнопкой рядом — иначе долг будет не за кем." + ) + return None + + def accept(self) -> None: + """Не выпускать долг без имени. + + Такая запись невосстановима: на экране долгов она попадает в кучу + «без контрагента», и с кого спрашивать деньги — уже не узнать. + """ + problem = self.problem() + if problem: + QMessageBox.warning(self, "Кому в долг?", problem) + self.counterparty.setFocus() + return + super().accept() + def current_kind(self) -> str: return self.kind.currentData() @@ -140,7 +258,12 @@ class SaleDialog(QDialog): if self.paid is not None: self.paid.setEnabled(not consumption) if consumption: + # Съеденное и подаренное денег не приносит: долга тут не бывает, + # поэтому и выбирать нечего. + self._show_pay_mode(PAY_FULL) self.paid.set_decimal(0) + for button in self.pay_buttons.values(): + button.setEnabled(not consumption) if self.tip is not None: self.tip.setEnabled(not consumption) if consumption: @@ -148,56 +271,83 @@ class SaleDialog(QDialog): self._sync_payment_default() def _sync_payment_default(self) -> None: - """Подставить полную оплату — самый частый случай.""" - if self.paid is not None and self.current_kind() not in CONSUMPTION_KINDS: + """Пересчитать оплату вслед за строками. + + Только в режиме «заплатил полностью». Иначе правка количества затирала + бы выставленный долг: вписал ноль, поправил строку — и продажа снова + оплачена целиком, причём молча. + """ + if ( + self.paid is not None + and self.pay_mode == PAY_FULL + and self.current_kind() not in CONSUMPTION_KINDS # Пока поле в фокусе, не перебиваем то, что человек набирает. - if not self.paid.hasFocus(): - self.paid.set_decimal(self.lines.total()) + and not self.paid.hasFocus() + ): + self.paid.set_decimal(self.lines.total()) self._update_payment_hint() - def _absorb_overpayment(self) -> None: + def _absorb_overpayment(self): """Дали больше суммы — разница уходит в чаевые. - Булка стоит 70, дали 100, сдачи не надо. Оставлять 100 в «оплачено» + Булка стоит 70, дали 100, сдачи не надо. Оставлять 100 в «получено» нельзя: столько за товар никто не должен, и продажа выглядела бы переплаченной вместо того, чтобы показать чаевые. """ if self.paid is None or self.tip is None: - return + return m.ZERO total = self.lines.total() extra = m.money(self.paid.value_decimal() - total) if extra <= 0: - self._update_payment_hint() - return + return m.ZERO self.paid.set_decimal(total) self.tip.set_decimal(self.tip.value_decimal() + extra) - self.paid_hint.setText( - f"Переплата {m.fmt_money(extra, self.ctx.currency)} записана в чаевые." - ) + return extra - def _update_payment_hint(self) -> None: + def current_debt(self): + if self.paid is None: + return m.ZERO + return max(m.ZERO, m.money(self.lines.total() - self.paid.value_decimal())) + + def _update_payment_hint(self, overpaid=None) -> None: if self.paid_hint is None: return if self.current_kind() in CONSUMPTION_KINDS: - self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.") + self._say("Денег нет по определению, но себестоимость пекарне вернуть надо.") return currency = self.ctx.currency tip = self.tip.value_decimal() if self.tip is not None else m.ZERO + debt = self.current_debt() + nameless = debt > 0 and not self.counterparty.currentData() parts = [] + if overpaid: + parts.append(f"Переплата {m.fmt_money(overpaid, currency)} записана в чаевые.") + if self.paid is not None: - debt = m.money(self.lines.total() - self.paid.value_decimal()) - parts.append( - f"В долг уйдёт {m.fmt_money(debt, currency)}." - if debt > 0 - else "Оплачено полностью." - ) + if debt > 0: + # Долг без имени взыскать не с кого: на экране долгов он + # окажется в куче «без контрагента» и там и останется. + parts.append( + f"В долг уйдёт {m.fmt_money(debt, currency)} — " + "укажи, кому, иначе будет непонятно, с кого спрашивать." + if nameless + else f"В долг уйдёт {m.fmt_money(debt, currency)}. " + "Долг появится на экране «Долги»." + ) + else: + parts.append("Оплачено полностью.") if tip > 0: parts.append(f"Чаевые {m.fmt_money(tip, currency)} идут в покрытие партии и в прибыль.") - self.paid_hint.setText(" ".join(parts)) + + self._say(" ".join(parts), theme.WARN if nameless else "") + + def _say(self, text: str, color: str = "") -> None: + self.paid_hint.setText(text) + self.paid_hint.setStyleSheet(f"color: {color};" if color else "") def _add_counterparty(self) -> None: from PySide6.QtWidgets import QInputDialog diff --git a/app/ui/quick_sales.py b/app/ui/quick_sales.py index 44b31ab..43ffbab 100644 --- a/app/ui/quick_sales.py +++ b/app/ui/quick_sales.py @@ -251,6 +251,10 @@ class QuickSalesDialog(QDialog): paid = w.MoneySpin(self.ctx.currency) paid.setProperty(TOUCHED, False) + paid.setToolTip( + "Сколько получено сразу. Обнули — продажа уйдёт в долг целиком; " + "поставь часть — в долг уйдёт остаток. Не забудь заполнить «Кому»." + ) paid.valueChanged.connect( lambda _, wid=paid: self._on_paid_edited(w.row_of(self.table, wid)) ) @@ -459,6 +463,20 @@ class QuickSalesDialog(QDialog): QMessageBox.information(self, "Пусто", "Заполни хотя бы одну строку.") return + # Долг без имени взыскать не с кого. Здесь это предупреждение, а не + # запрет: при вводе истории можно и правда не помнить, кто это был. + nameless = nameless_debts(rows) + if nameless: + confirm = QMessageBox.question( + self, + "Долг без имени", + f"Строк с долгом, но без указания «Кому»: {len(nameless)}.\n\n" + "Такой долг попадёт в кучу «без контрагента», и с кого " + "спрашивать — будет не видно. Всё равно записать?", + ) + if confirm != QMessageBox.Yes: + return + try: created = write_sales(self.ctx.vault, rows) except journal.ValidationError as exc: @@ -469,6 +487,20 @@ class QuickSalesDialog(QDialog): super().accept() +def _row_sum(row: dict) -> Decimal: + line = row["line"] + return m.money(line["qty"] * line["unit_price"]) + + +def nameless_debts(rows: list[dict]) -> list[dict]: + """Строки, где остался долг, но не указано, за кем он. + + Отдельной функцией, а не внутри accept: правило должно быть видно тесту, + а не только человеку в модальном окне. + """ + return [row for row in rows if not row["who"] and row["paid"] < _row_sum(row)] + + def write_sales(vault, rows: list[dict]) -> int: """Записать собранные строки как отдельные продажи. diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 5296fd5..6739296 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -459,6 +459,116 @@ def test_mixed_pack_and_single_purchases_average_the_cost(vault, today, in_two_w assert batch.stock_cost == Decimal("425.00") # 25 штук по 17 ₽ в среднем +def test_pack_prices_change_through_history(vault, today): + """Цена коробки меняется только там же, где цена штуки, — с историей. + + Иначе у товара история есть, а у фасовки нет, хотя дорожают они обычно + одним решением поставщика. + """ + product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25) + journal.set_packs( + vault, product.id, + [{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}], + ) + + journal.change_prices( + vault, product.id, 28, 30, today, "подорожала мука", + packs=[{"name": "коробка", "cost_price": 340, "retail_price": 560}], + ) + product = vault.doc.product(product.id) + + assert product.cost_for("коробка") == Decimal("340.00") + assert product.retail_for("коробка") == Decimal("560.00") + + # Обе точки истории знают цены фасовки, включая стартовую. + assert [p.pack("коробка").cost_price for p in product.price_history] == [ + Decimal("300.00"), Decimal("340.00") + ] + + +def test_editing_pack_size_does_not_touch_its_prices(vault): + """Правка состава фасовок — не правка цен, и молча их сбрасывать нельзя.""" + product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25) + journal.set_packs( + vault, product.id, + [{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}], + ) + + # Карточка товара присылает нули: цены там показаны только для чтения. + journal.set_packs( + vault, product.id, + [{"name": "коробка", "size": 24, "cost_price": 0, "retail_price": 0}], + ) + pack = vault.doc.product(product.id).packs[0] + + assert pack.size == Decimal("24.000") + assert pack.cost_price == Decimal("300.00") + assert pack.retail_price == Decimal("500.00") + + +def test_new_pack_price_lands_in_history(vault, today): + product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25) + journal.change_prices(vault, product.id, 26, 27, today, "первое подорожание") + journal.set_packs( + vault, product.id, + [{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}], + ) + product = vault.doc.product(product.id) + + # Отдельной точкой: приписывать коробке цену задним числом было бы враньём. + assert "заведена фасовка «коробка»" in product.price_history[-1].note + assert product.price_history[-1].pack("коробка").cost_price == Decimal("300.00") + assert product.price_history[0].packs == [] + + +def test_price_change_without_packs_keeps_them(vault, today): + """packs=None — «фасовки не трогаем», но снимок всё равно полный.""" + product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25) + journal.set_packs( + vault, product.id, + [{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}], + ) + journal.change_prices(vault, product.id, 28, 30, today, "только штука") + product = vault.doc.product(product.id) + + assert product.cost_for("коробка") == Decimal("300.00") + assert product.price_history[-1].pack("коробка").cost_price == Decimal("300.00") + + +def test_price_change_of_only_a_pack_is_recorded(vault, today): + """Подорожала только коробка — это тоже изменение, а не пустая правка.""" + product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25) + journal.set_packs( + vault, product.id, + [{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}], + ) + before = len(vault.doc.product(product.id).price_history) + + journal.change_prices( + vault, product.id, 25, 25, today, "коробка подорожала", + packs=[{"name": "коробка", "cost_price": 320, "retail_price": 500}], + ) + product = vault.doc.product(product.id) + + assert len(product.price_history) == before + 1 + assert product.cost_for("коробка") == Decimal("320.00") + + +def test_unchanged_prices_add_nothing(vault, today): + product = journal.create_product(vault, "Печенье", unit="шт", cost_price=25, retail_price=25) + journal.set_packs( + vault, product.id, + [{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}], + ) + before = len(vault.doc.product(product.id).price_history) + + journal.change_prices( + vault, product.id, 25, 25, today, "ничего не менял", + packs=[{"name": "коробка", "cost_price": 300, "retail_price": 500}], + ) + assert len(vault.doc.product(product.id).price_history) == before + + def test_pack_without_its_own_cost_still_derives_it(vault, today, in_two_weeks): """Пустая цена фасовки — прежнее поведение, а не ноль.""" product = journal.create_product(vault, "Печенье", unit="шт", cost_price=20, retail_price=30) diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 9513feb..2cf5a8c 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -13,10 +13,10 @@ import pytest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from PySide6.QtWidgets import QApplication # noqa: E402 +from PySide6.QtWidgets import QApplication, QDialog # noqa: E402 from app import journal # noqa: E402 -from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF # noqa: E402 +from app.models import KIND_FRIEND, KIND_RETAIL, KIND_SELF, SALE_KINDS # 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 @@ -983,6 +983,148 @@ def test_empty_chart_says_so(qapp): chart.deleteLater() +# --- «взял в долг» -------------------------------------------------------- + + +def sale_of(window, buns, qty=3): + 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(qty) + return dialog, editor + + +def test_debt_mode_survives_editing_the_lines(window, buns): + """Выставил долг, поправил количество — долг обязан остаться долгом. + + Раньше подстановка полной оплаты срабатывала на любое изменение строк + и молча затирала выставленный ноль: продажа снова становилась оплаченной, + и заметить это было нечем. + """ + from app.ui.page_sales import PAY_DEBT, PAY_FULL + + dialog, editor = sale_of(window, buns, qty=3) + assert dialog.pay_mode == PAY_FULL + assert dialog.paid.value_decimal() == Decimal("150.00") + + dialog.pay_buttons[PAY_DEBT].setChecked(True) + assert dialog.paid.value_decimal() == 0 + + editor.table.cellWidget(0, editor.COL_QTY).set_decimal(4) + assert dialog.pay_mode == PAY_DEBT + assert dialog.paid.value_decimal() == 0 + assert dialog.current_debt() == Decimal("200.00") + dialog.deleteLater() + + +def test_hint_stops_claiming_full_payment_after_zeroing(window, buns): + """Обнулил оплату — подсказка обязана сказать про долг, а не «оплачено».""" + dialog, _ = sale_of(window, buns, qty=3) + assert "полностью" in dialog.paid_hint.text().lower() + + dialog.paid.set_decimal(0) + assert "полностью" not in dialog.paid_hint.text().lower() + assert "150,00 ₽" in dialog.paid_hint.text() + dialog.deleteLater() + + +def test_typed_amount_lights_up_the_matching_mode(window, buns): + from app.ui.page_sales import PAY_DEBT, PAY_FULL, PAY_PART + + dialog, _ = sale_of(window, buns, qty=3) + + dialog.paid.set_decimal(0) + dialog._on_paid_edited() + assert dialog.pay_buttons[PAY_DEBT].isChecked() is True + + dialog.paid.set_decimal(50) + dialog._on_paid_edited() + assert dialog.pay_buttons[PAY_PART].isChecked() is True + + dialog.paid.set_decimal(150) + dialog._on_paid_edited() + assert dialog.pay_buttons[PAY_FULL].isChecked() is True + dialog.deleteLater() + + +def test_debt_without_a_person_is_refused(window, buns): + """Долг без имени невзыскуем — на экране долгов он безымянный.""" + from app.ui.page_sales import PAY_DEBT + + dialog, _ = sale_of(window, buns, qty=3) + dialog.pay_buttons[PAY_DEBT].setChecked(True) + assert theme.WARN in dialog.paid_hint.styleSheet() + assert "укажи, кому" in dialog.paid_hint.text() + + assert "не указано кому" in dialog.problem() + + dialog.counterparty.setCurrentIndex(dialog.counterparty.findData(window.vasya.id)) + assert "укажи, кому" not in dialog.paid_hint.text() + assert dialog.problem() is None + dialog.deleteLater() + + +def test_full_payment_needs_no_person(window, buns): + dialog, _ = sale_of(window, buns, qty=3) + assert dialog.problem() is None + dialog.deleteLater() + + +def test_consumption_has_no_payment_choice(window, buns): + dialog, _ = sale_of(window, buns, qty=3) + dialog.kind.setCurrentIndex(SALE_KINDS.index(KIND_SELF)) + assert all(not b.isEnabled() for b in dialog.pay_buttons.values()) + assert dialog.paid.isEnabled() is False + dialog.deleteLater() + + +def test_debts_page_settles_a_person_across_sales(window, buns): + """Вася отдаёт одну сумму — она должна лечь на все его долги, со старых.""" + second = journal.create_sale( + window.vault, + date.today() - timedelta(days=5), + KIND_RETAIL, + [{"product_id": buns["повидло"].id, "qty": 2, "unit_price": 50}], + counterparty_id=window.vasya.id, + paid_amount=0, + ) + window.changed() + + page = page_of(window, "Долги") + person = page.tree.topLevelItem(0) + page.tree.setCurrentItem(person) + + debtor = page._selected_debtor() + assert debtor is not None + assert debtor.debt == Decimal("200.00") # 100 старый + 100 новый + + applied = journal.pay_off_counterparty(window.vault, window.vasya.id, 150) + window.changed() + + # Старый долг гасится первым и целиком, остаток уходит на следующий. + assert [amount for _, amount in applied] == [Decimal("100.00"), Decimal("50.00")] + assert applied[0][0].id == second.id # он на пять дней старше + assert window.report.summary.receivable == Decimal("50.00") + + +def test_paying_a_person_more_than_owed_becomes_a_tip(window): + journal.pay_off_counterparty(window.vault, window.vasya.id, 130) + window.changed() + + sale = next(s for s in window.vault.doc.sales if s.counterparty_id == window.vasya.id) + assert sale.debt == 0 + assert sale.tip == Decimal("30.00") + + +def test_selecting_a_sale_still_pays_only_that_sale(window, buns): + page = page_of(window, "Долги") + person = page.tree.topLevelItem(0) + page.tree.setCurrentItem(person.child(0)) + + assert page._selected_debtor() is None + assert page._selected_sale() is not None + + # --- чаевые ---------------------------------------------------------------