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

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

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

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

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

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

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

View File

@ -35,6 +35,7 @@ from .models import (
Counterparty, Counterparty,
JournalEntry, JournalEntry,
Pack, Pack,
PackPrice,
Payment, Payment,
PricePoint, PricePoint,
Product, Product,
@ -268,6 +269,13 @@ def update_product(
return 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( def change_prices(
vault, vault,
product_id: str, product_id: str,
@ -275,8 +283,17 @@ def change_prices(
retail_price, retail_price,
on_date: date | None = None, on_date: date | None = None,
note: str = "", note: str = "",
packs: list[dict] | None = None,
) -> Product: ) -> Product:
"""Поменять цены товара, оставив след в истории цен.""" """Поменять цены товара и его фасовок, оставив след в истории.
Цены фасовок меняются только здесь. Раньше они правились в карточке
товара и уезжали молча: у товара история была, у коробки нет, хотя
дорожают они обычно одновременно и одним решением.
packs=None означает «фасовки не трогаем» их цены всё равно попадут
в снимок, чтобы история была полной.
"""
doc = vault.doc doc = vault.doc
product = doc.product(product_id) product = doc.product(product_id)
if product is None: if product is None:
@ -286,18 +303,45 @@ def change_prices(
retail_price = m.money(retail_price) retail_price = m.money(retail_price)
if cost_price < 0 or retail_price < 0: if cost_price < 0 or retail_price < 0:
raise ValidationError("Цена не может быть отрицательной.") 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 return product
before = _flat_product(doc, product) before = _flat_product(doc, product)
product.cost_price = cost_price product.cost_price = cost_price
product.retail_price = retail_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( product.price_history.append(
PricePoint( PricePoint(
date=on_date or date.today(), date=on_date or date.today(),
cost_price=cost_price, cost_price=cost_price,
retail_price=retail_price, retail_price=retail_price,
note=note.strip(), note=note.strip(),
packs=_snapshot_packs(product),
) )
) )
_record( _record(
@ -312,7 +356,14 @@ def change_prices(
def set_packs(vault, product_id: str, packs: list[dict]) -> Product: def set_packs(vault, product_id: str, packs: list[dict]) -> Product:
"""Задать фасовки товара. """Задать состав фасовок: названия и размеры.
Цены существующих фасовок здесь не меняются для них есть change_prices,
который пишет историю. Иначе цена коробки правилась бы из двух мест, и из
одного из них она уезжала бы молча.
Цены берутся только у новых фасовок: их первое значение это заведение,
а не изменение, и истории до него нет.
Уже записанные документы помнят размер фасовки слепком, поэтому правка Уже записанные документы помнят размер фасовки слепком, поэтому правка
здесь не пересчитывает историю меняется только то, что подставится здесь не пересчитывает историю меняется только то, что подставится
@ -339,6 +390,10 @@ def set_packs(vault, product_id: str, packs: list[dict]) -> Product:
# Обе цены — за фасовку целиком. Ноль значит «своей цены нет, # Обе цены — за фасовку целиком. Ноль значит «своей цены нет,
# считай от цены базовой единицы». # считай от цены базовой единицы».
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")) retail = m.money(raw.get("retail_price"))
cost = m.money(raw.get("cost_price")) cost = m.money(raw.get("cost_price"))
if retail < 0 or cost < 0: if retail < 0 or cost < 0:
@ -346,13 +401,47 @@ def set_packs(vault, product_id: str, packs: list[dict]) -> Product:
built.append(Pack(name=name, size=size, retail_price=retail, cost_price=cost)) built.append(Pack(name=name, size=size, retail_price=retail, cost_price=cost))
before = _flat_product(doc, product) before = _flat_product(doc, product)
known = {p.name for p in product.packs}
product.packs = built 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)) changes = _diff(before, _flat_product(doc, product))
if changes: if changes:
_record(vault, "product.packs", "product", product.id, product.name, changes) _record(vault, "product.packs", "product", product.id, product.name, changes)
return product 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: 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 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: def set_sale_tip(vault, sale_id: str, amount) -> Sale:
"""Задать чаевые по уже записанной продаже.""" """Задать чаевые по уже записанной продаже."""
doc = vault.doc doc = vault.doc

View File

@ -15,7 +15,7 @@ from typing import Any
from . import money as m from . import money as m
SCHEMA_VERSION = 5 SCHEMA_VERSION = 6
ONE = Decimal(1) 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 @dataclass
class PricePoint: class PricePoint:
"""Снимок цен товара на момент изменения. """Снимок цен товара на момент изменения.
Пишется при каждой правке цены, поэтому история цен видна прямо в карточке Пишется при каждой правке цены, поэтому история цен видна прямо в карточке
товара, не полагаясь на историю git. товара, не полагаясь на историю git. Цены фасовок входят в тот же снимок:
иначе у товара история была бы, а у коробки нет, хотя дорожают они
обычно вместе.
""" """
date: date date: date
cost_price: Decimal cost_price: Decimal
retail_price: Decimal retail_price: Decimal
note: str = "" 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 @classmethod
def from_dict(cls, d: dict) -> "PricePoint": def from_dict(cls, d: dict) -> "PricePoint":
@ -85,6 +119,7 @@ class PricePoint:
cost_price=m.money(d.get("cost_price")), cost_price=m.money(d.get("cost_price")),
retail_price=m.money(d.get("retail_price")), retail_price=m.money(d.get("retail_price")),
note=d.get("note", ""), note=d.get("note", ""),
packs=[PackPrice.from_dict(p) for p in d.get("packs", [])],
) )
def to_dict(self) -> dict: def to_dict(self) -> dict:
@ -93,6 +128,7 @@ class PricePoint:
"cost_price": m.dumps(self.cost_price), "cost_price": m.dumps(self.cost_price),
"retail_price": m.dumps(self.retail_price), "retail_price": m.dumps(self.retail_price),
"note": self.note, "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 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 = { _MIGRATIONS = {
0: _migrate_0_to_1, 0: _migrate_0_to_1,
1: _migrate_1_to_2, 1: _migrate_1_to_2,
2: _migrate_2_to_3, 2: _migrate_2_to_3,
3: _migrate_3_to_4, 3: _migrate_3_to_4,
4: _migrate_4_to_5, 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 journal
from .. import money as m from .. import money as m
from ..models import SALE_KIND_LABELS from ..models import SALE_KIND_LABELS
from ..salesfilter import plural
from . import theme from . import theme
from . import widgets as w from . import widgets as w
ROLE_SALE_ID = Qt.UserRole + 2 ROLE_SALE_ID = Qt.UserRole + 2
ROLE_COUNTERPARTY = Qt.UserRole + 3
class PaymentDialog(QDialog): 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) super().__init__(parent)
self.setWindowTitle("Принять оплату") self.setWindowTitle("Принять оплату")
self.setMinimumWidth(340) self.setMinimumWidth(340)
@ -52,16 +54,18 @@ class PaymentDialog(QDialog):
form.addRow("Заметка", self.note) form.addRow("Заметка", self.note)
layout.addLayout(form) 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( layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Принять", "primary", self.accept)) w.row(None, w.button("Отмена", on_click=self.reject), w.button("Принять", "primary", self.accept))
) )
self.amount.setFocus() 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, если отменили.""" """Спросить сумму оплаты. Возвращает None, если отменили."""
dialog = PaymentDialog(suggested, currency, parent) dialog = PaymentDialog(suggested, currency, parent, what)
if dialog.exec() != QDialog.Accepted: if dialog.exec() != QDialog.Accepted:
return None return None
return dialog.amount.value_decimal() return dialog.amount.value_decimal()
@ -88,6 +92,13 @@ class DebtsPage(QWidget):
self.total_label, self.total_label,
) )
) )
layout.addWidget(
w.label(
"Выбери человека — рассчитаешься сразу за все его долги, начиная "
"со старых. Выбери отдельную продажу — оплата пойдёт только за неё.",
"dim",
)
)
self.tree = QTreeWidget() self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Кто / что", "Дата", "Сумма", "Оплачено", "Долг"]) self.tree.setHeaderLabels(["Кто / что", "Дата", "Сумма", "Оплачено", "Долг"])
@ -119,6 +130,9 @@ class DebtsPage(QWidget):
parent.setFont(4, font) parent.setFont(4, font)
parent.setForeground(4, QColor(theme.WARN)) parent.setForeground(4, QColor(theme.WARN))
parent.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter) 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): for sale in sorted(debtor.sales, key=lambda s: s.date):
what = ", ".join( 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): def _selected_sale(self):
item = self.tree.currentItem() item = self.tree.currentItem()
if item is None: if item is None:
QMessageBox.information(self, "Не выбрано", "Выбери конкретную продажу.") QMessageBox.information(self, "Не выбрано", "Выбери человека или продажу.")
return None return None
sale_id = item.data(0, ROLE_SALE_ID) sale_id = item.data(0, ROLE_SALE_ID)
if sale_id is None: if not sale_id:
QMessageBox.information( QMessageBox.information(
self, "Выбери продажу", "Раскрой человека и выбери, за что он платит." self, "Не выбрано", "Выбери человека или конкретную продажу."
) )
return None return None
return self.ctx.vault.doc.sale(sale_id) return self.ctx.vault.doc.sale(sale_id)
def take_payment(self) -> None: def take_payment(self) -> None:
debtor = self._selected_debtor()
if debtor is not None:
self._pay_debtor(debtor)
return
sale = self._selected_sale() sale = self._selected_sale()
if sale is None: if sale is None:
return return
@ -182,11 +211,45 @@ class DebtsPage(QWidget):
return return
self.ctx.changed() self.ctx.changed()
def settle_full(self) -> None: def _pay_debtor(self, debtor) -> None:
sale = self._selected_sale() """Принять деньги от человека сразу за всё, что за ним числится."""
if sale is 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 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 return
journal.add_sale_payment(self.ctx.vault, sale.id, sale.debt) journal.add_sale_payment(self.ctx.vault, sale.id, sale.debt)
self.ctx.changed() self.ctx.changed()

View File

@ -73,11 +73,16 @@ class PacksEditor(QWidget):
packs=(), packs=(),
cost_source=None, cost_source=None,
retail_source=None, retail_source=None,
lock_prices: bool = False,
parent=None, parent=None,
): ):
super().__init__(parent) super().__init__(parent)
self.currency = currency self.currency = currency
self.base_unit = base_unit self.base_unit = base_unit
# У заведённой фасовки цена правится только через «Цены и история» —
# там она попадает в историю. Здесь её показываем, но не даём трогать,
# иначе цена коробки менялась бы из двух мест и из одного молча.
self.lock_prices = lock_prices
# Откуда брать цены базовой единицы: по ним считается фасовка, # Откуда брать цены базовой единицы: по ним считается фасовка,
# у которой своей цены нет. # у которой своей цены нет.
self.cost_source = cost_source or (lambda: m.ZERO) self.cost_source = cost_source or (lambda: m.ZERO)
@ -117,7 +122,9 @@ class PacksEditor(QWidget):
layout.addWidget(self.derived) layout.addWidget(self.derived)
for pack in packs: 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() self.refresh_hint()
def refresh_hint(self) -> None: def refresh_hint(self) -> None:
@ -139,7 +146,10 @@ class PacksEditor(QWidget):
) )
for pack in packs 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: def set_base_unit(self, unit: str) -> None:
self.base_unit = unit or "шт" self.base_unit = unit or "шт"
@ -148,7 +158,7 @@ class PacksEditor(QWidget):
) )
self.refresh_hint() 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() r = self.table.rowCount()
self.table.insertRow(r) self.table.insertRow(r)
@ -173,6 +183,12 @@ class PacksEditor(QWidget):
price_spin.set_decimal(price) price_spin.set_decimal(price)
price_spin.valueChanged.connect(self.refresh_hint) price_spin.valueChanged.connect(self.refresh_hint)
self.table.setCellWidget(r, self.COL_RETAIL, price_spin) 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() self.refresh_hint()
def remove_current(self) -> None: 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, 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, 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) self.unit.textChanged.connect(self.packs.set_base_unit)
if product is None: 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): class PriceDialog(QDialog):
"""Смена цен с записью в историю.""" """Смена цен с записью в историю — и товара, и его фасовок."""
def __init__(self, currency: str, product, parent=None): def __init__(self, currency: str, product, parent=None):
super().__init__(parent) super().__init__(parent)
self.product = product self.product = product
self.setWindowTitle(f"Цены — {product.name}") self.setWindowTitle(f"Цены — {product.name}")
self.setMinimumWidth(520) self.setMinimumWidth(660)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 16, 20, 16)
@ -295,11 +405,11 @@ class PriceDialog(QDialog):
self.cost = w.MoneySpin(currency) self.cost = w.MoneySpin(currency)
self.cost.set_decimal(product.cost_price) 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 = w.MoneySpin(currency)
self.retail.set_decimal(product.retail_price) self.retail.set_decimal(product.retail_price)
form.addRow("Цена продажи", self.retail) form.addRow(f"Цена продажи за {product.unit}", self.retail)
self.date = w.DateInput() self.date = w.DateInput()
form.addRow("С даты", self.date) form.addRow("С даты", self.date)
@ -309,9 +419,25 @@ class PriceDialog(QDialog):
form.addRow("Причина", self.note) form.addRow("Причина", self.note)
layout.addLayout(form) 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")) layout.addWidget(w.label("История цен", "h2"))
history = w.table(["Дата", "Себестоимость", "Цена продажи", "Причина"], stretch_column=3) history = w.table(
["Дата", "Себестоимость", "Цена продажи", "Фасовки", "Причина"], stretch_column=4
)
history.setSortingEnabled(False) history.setSortingEnabled(False)
history.setMaximumHeight(220) history.setMaximumHeight(220)
rows = [ rows = [
@ -319,6 +445,7 @@ class PriceDialog(QDialog):
w.text_item(point.date.strftime("%d.%m.%Y")), 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.cost_price, currency), point.cost_price),
w.num_item(m.fmt_money(point.retail_price, currency), point.retail_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), w.text_item(point.note),
] ]
for point in reversed(product.price_history) 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)) 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): class ProductsPage(QWidget):
def __init__(self, ctx, parent=None): def __init__(self, ctx, parent=None):
@ -478,6 +608,7 @@ class ProductsPage(QWidget):
dialog.retail.value_decimal(), dialog.retail.value_decimal(),
dialog.date.get_date(), dialog.date.get_date(),
dialog.note.text(), dialog.note.text(),
packs=dialog.pack_prices(),
) )
except journal.ValidationError as exc: except journal.ValidationError as exc:
QMessageBox.warning(self, "Не получилось", str(exc)) QMessageBox.warning(self, "Не получилось", str(exc))

View File

@ -4,11 +4,13 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup,
QComboBox, QComboBox,
QDialog, QDialog,
QFormLayout, QFormLayout,
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QRadioButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@ -27,6 +29,37 @@ from . import theme
from . import widgets as w 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): def price_source_for(kind: str):
"""Откуда берётся цена по умолчанию для этого типа выбытия и фасовки. """Откуда берётся цена по умолчанию для этого типа выбытия и фасовки.
@ -74,6 +107,8 @@ class SaleDialog(QDialog):
index = self.counterparty.findData(sale.counterparty_id) index = self.counterparty.findData(sale.counterparty_id)
if index >= 0: if index >= 0:
self.counterparty.setCurrentIndex(index) 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)) form.addRow("Кому", w.row(self.counterparty, w.button("+ Новый", on_click=self._add_counterparty), stretch_at=0))
self.note = QLineEdit(sale.note if sale else "") self.note = QLineEdit(sale.note if sale else "")
@ -87,6 +122,8 @@ class SaleDialog(QDialog):
self.paid = None self.paid = None
self.tip = None self.tip = None
self.paid_hint = None self.paid_hint = None
self.pay_mode = None
self.pay_buttons = {}
layout.addWidget(w.label("Что ушло", "h2")) layout.addWidget(w.label("Что ушло", "h2"))
products = [p for p in ctx.vault.doc.products if not p.archived] products = [p for p in ctx.vault.doc.products if not p.archived]
@ -104,11 +141,16 @@ class SaleDialog(QDialog):
# живут своей жизнью и правятся на экране долгов. # живут своей жизнью и правятся на экране долгов.
pay_form = QFormLayout() pay_form = QFormLayout()
if sale is None: if sale is None:
pay_form.addRow("Как рассчитались", self._pay_mode_row())
self.paid = w.MoneySpin(ctx.currency) self.paid = w.MoneySpin(ctx.currency)
# Переплату разбираем, когда человек закончил вводить сумму, # Переплату разбираем, когда человек закончил вводить сумму,
# а не на каждом нажатии: иначе поле дёргалось бы посреди набора. # а не на каждом нажатии: иначе поле дёргалось бы посреди набора.
self.paid.editingFinished.connect(self._absorb_overpayment) self.paid.editingFinished.connect(self._on_paid_edited)
pay_form.addRow("Оплачено сразу", self.paid) # Без этого поле можно было обнулить, а подсказка продолжала
# уверять, что оплачено полностью.
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() 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: def current_kind(self) -> str:
return self.kind.currentData() return self.kind.currentData()
@ -140,7 +258,12 @@ class SaleDialog(QDialog):
if self.paid is not None: if self.paid is not None:
self.paid.setEnabled(not consumption) self.paid.setEnabled(not consumption)
if consumption: if consumption:
# Съеденное и подаренное денег не приносит: долга тут не бывает,
# поэтому и выбирать нечего.
self._show_pay_mode(PAY_FULL)
self.paid.set_decimal(0) self.paid.set_decimal(0)
for button in self.pay_buttons.values():
button.setEnabled(not consumption)
if self.tip is not None: if self.tip is not None:
self.tip.setEnabled(not consumption) self.tip.setEnabled(not consumption)
if consumption: if consumption:
@ -148,56 +271,83 @@ class SaleDialog(QDialog):
self._sync_payment_default() self._sync_payment_default()
def _sync_payment_default(self) -> None: 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(): and not self.paid.hasFocus()
):
self.paid.set_decimal(self.lines.total()) self.paid.set_decimal(self.lines.total())
self._update_payment_hint() 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: if self.paid is None or self.tip is None:
return return m.ZERO
total = self.lines.total() total = self.lines.total()
extra = m.money(self.paid.value_decimal() - total) extra = m.money(self.paid.value_decimal() - total)
if extra <= 0: if extra <= 0:
self._update_payment_hint() return m.ZERO
return
self.paid.set_decimal(total) self.paid.set_decimal(total)
self.tip.set_decimal(self.tip.value_decimal() + extra) self.tip.set_decimal(self.tip.value_decimal() + extra)
self.paid_hint.setText( return extra
f"Переплата {m.fmt_money(extra, self.ctx.currency)} записана в чаевые."
)
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: if self.paid_hint is None:
return return
if self.current_kind() in CONSUMPTION_KINDS: if self.current_kind() in CONSUMPTION_KINDS:
self.paid_hint.setText("Денег нет по определению, но себестоимость пекарне вернуть надо.") self._say("Денег нет по определению, но себестоимость пекарне вернуть надо.")
return return
currency = self.ctx.currency currency = self.ctx.currency
tip = self.tip.value_decimal() if self.tip is not None else m.ZERO 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 = [] parts = []
if overpaid:
parts.append(f"Переплата {m.fmt_money(overpaid, currency)} записана в чаевые.")
if self.paid is not None: if self.paid is not None:
debt = m.money(self.lines.total() - self.paid.value_decimal()) if debt > 0:
# Долг без имени взыскать не с кого: на экране долгов он
# окажется в куче «без контрагента» и там и останется.
parts.append( parts.append(
f"В долг уйдёт {m.fmt_money(debt, currency)}." f"В долг уйдёт {m.fmt_money(debt, currency)}"
if debt > 0 "укажи, кому, иначе будет непонятно, с кого спрашивать."
else "Оплачено полностью." if nameless
else f"В долг уйдёт {m.fmt_money(debt, currency)}. "
"Долг появится на экране «Долги»."
) )
else:
parts.append("Оплачено полностью.")
if tip > 0: if tip > 0:
parts.append(f"Чаевые {m.fmt_money(tip, currency)} идут в покрытие партии и в прибыль.") 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: def _add_counterparty(self) -> None:
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog

View File

@ -251,6 +251,10 @@ class QuickSalesDialog(QDialog):
paid = w.MoneySpin(self.ctx.currency) paid = w.MoneySpin(self.ctx.currency)
paid.setProperty(TOUCHED, False) paid.setProperty(TOUCHED, False)
paid.setToolTip(
"Сколько получено сразу. Обнули — продажа уйдёт в долг целиком; "
"поставь часть — в долг уйдёт остаток. Не забудь заполнить «Кому»."
)
paid.valueChanged.connect( paid.valueChanged.connect(
lambda _, wid=paid: self._on_paid_edited(w.row_of(self.table, wid)) lambda _, wid=paid: self._on_paid_edited(w.row_of(self.table, wid))
) )
@ -459,6 +463,20 @@ class QuickSalesDialog(QDialog):
QMessageBox.information(self, "Пусто", "Заполни хотя бы одну строку.") QMessageBox.information(self, "Пусто", "Заполни хотя бы одну строку.")
return return
# Долг без имени взыскать не с кого. Здесь это предупреждение, а не
# запрет: при вводе истории можно и правда не помнить, кто это был.
nameless = nameless_debts(rows)
if nameless:
confirm = QMessageBox.question(
self,
"Долг без имени",
f"Строк с долгом, но без указания «Кому»: {len(nameless)}.\n\n"
"Такой долг попадёт в кучу «без контрагента», и с кого "
"спрашивать — будет не видно. Всё равно записать?",
)
if confirm != QMessageBox.Yes:
return
try: try:
created = write_sales(self.ctx.vault, rows) created = write_sales(self.ctx.vault, rows)
except journal.ValidationError as exc: except journal.ValidationError as exc:
@ -469,6 +487,20 @@ class QuickSalesDialog(QDialog):
super().accept() 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: 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 ₽ в среднем 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): 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) 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") 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 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 import theme # noqa: E402
from app.ui.main_window import MainWindow # noqa: E402 from app.ui.main_window import MainWindow # noqa: E402
from app.ui.page_batches import BatchDialog # noqa: E402 from app.ui.page_batches import BatchDialog # noqa: E402
@ -983,6 +983,148 @@ def test_empty_chart_says_so(qapp):
chart.deleteLater() 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
# --- чаевые --------------------------------------------------------------- # --- чаевые ---------------------------------------------------------------