Долг как явный выбор, история цен у фасовок

Долг было непонятно как записать, и не зря. Оплата была просто денежным
полем с полной суммой: долг получался, если догадаться стереть число
и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что
оплачено полностью, а любая правка строк подставляла полную сумму обратно
и молча стирала выставленный долг.

Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил
часть. Пересчёт работает только в первом режиме. Поле суммы осталось
живым — вписанное руками число само зажигает подходящий переключатель.

Долг без имени записать нельзя: на экране долгов такая запись попадает
в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром
вводе это предупреждение, а не запрет: внося историю, можно и правда не
помнить, кто это был.

На экране долгов человека можно выбрать целиком и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
food-market 2026-08-25 00:25:49 +03:00
parent e2795d92f8
commit d08d2421f7
10 changed files with 913 additions and 53 deletions

View File

@ -74,6 +74,39 @@
корзин больше 24, на графике показываются последние, и об этом написано прямо
над ним — молча показанная часть выглядела бы как всё.
### Взял в долг
В форме продажи выбор рассчёта явный, тремя переключателями:
| | |
|---|---|
| **Заплатил полностью** | по умолчанию, сумма подставляется сама |
| **Взял в долг целиком** | получено ноль, вся сумма уходит в долг |
| **Заплатил часть** | сколько дал — в поле, остаток в долг |
Раньше это было просто денежное поле, заполненное полной суммой: долг
получался, если догадаться стереть число и вписать ноль. Хуже того, любая
последующая правка строк подставляла полную оплату обратно — выставленный долг
исчезал молча, а подсказка продолжала уверять, что оплачено полностью.
Теперь пересчёт работает только в режиме «заплатил полностью».
Поле суммы остаётся живым: вписал число руками — загорится подходящий
переключатель. Ноль включает «в долг целиком», часть суммы — «заплатил часть»,
больше суммы — разница уходит в чаевые.
**Долг без имени записать нельзя.** Такая запись невосстановима: на экране
долгов она попадает в кучу «без контрагента», и с кого спрашивать деньги —
уже не узнать. Форма продажи не выпустит такую запись и подсветит подсказку;
контрагента можно завести кнопкой рядом с полем «Кому». В быстром вводе это
предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это
был.
На экране долгов можно выбрать **человека целиком** и рассчитаться сразу за
всё: деньги приходят одной суммой, а гасятся долги по очереди, начиная со
старых. Отдал больше, чем был должен, — излишек становится чаевыми, как и
переплата по одной продаже. Выбранная отдельная продажа по-прежнему гасится
сама по себе.
### Отбор продаж
Список продаж растёт быстрее всех остальных, поэтому у него своя панель
@ -196,6 +229,19 @@
так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры
в старых базах не поехали.
**Цены фасовок меняются там же, где цены товара** — кнопкой «Цены и история»,
и попадают в ту же историю. Дорожают они обычно одним решением поставщика, и
разводить их по разным окнам значило бы, что у товара история есть, а у коробки
нет. В карточке товара (кнопка «Изменить») цены фасовок показаны, но не
редактируются: иначе цена коробки менялась бы из двух мест, и из одного — молча.
Там правится только состав: название и размер.
Стартовая цена новой фасовки тоже попадает в историю — отдельной точкой
«заведена фасовка «коробка»» либо, если товар только что создан, прямо в его
точку создания. Приписывать коробке цену задним числом программа не станет:
у снимков, сделанных до её появления, фасовок нет, и пустое место честнее
выдуманных цифр.
Если один товар попал в партию и коробками, и поштучно, себестоимость базовой
единицы становится средневзвешенной: остаток по товару всё равно один, и при
поштучной продаже иначе было бы непонятно, какая из двух цен списывается.

View File

@ -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

View File

@ -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],
}

View File

@ -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,
}

View File

@ -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()

View File

@ -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))

View File

@ -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

View File

@ -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:
"""Записать собранные строки как отдельные продажи.

View File

@ -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)

View File

@ -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
# --- чаевые ---------------------------------------------------------------