Долг как явный выбор, история цен у фасовок
Долг было непонятно как записать, и не зря. Оплата была просто денежным полем с полной суммой: долг получался, если догадаться стереть число и вписать ноль. Дальше — хуже. Подсказка при этом продолжала уверять, что оплачено полностью, а любая правка строк подставляла полную сумму обратно и молча стирала выставленный долг. Теперь выбор явный: заплатил полностью / взял в долг целиком / заплатил часть. Пересчёт работает только в первом режиме. Поле суммы осталось живым — вписанное руками число само зажигает подходящий переключатель. Долг без имени записать нельзя: на экране долгов такая запись попадает в кучу «без контрагента», и с кого спрашивать — уже не узнать. В быстром вводе это предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это был. На экране долгов человека можно выбрать целиком и рассчитаться сразу за всё: деньги приходят одной суммой, а гасятся долги по очереди, со старых. Цены фасовок переехали в «Цены и история». Они меняются одним решением поставщика вместе с ценой товара, а история была только у товара — у коробки цена уезжала молча. Заодно закрыт второй вход: в карточке товара цены фасовок теперь только показываются, правится там состав. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e2795d92f8
commit
d08d2421f7
46
README.md
46
README.md
@ -74,6 +74,39 @@
|
|||||||
корзин больше 24, на графике показываются последние, и об этом написано прямо
|
корзин больше 24, на графике показываются последние, и об этом написано прямо
|
||||||
над ним — молча показанная часть выглядела бы как всё.
|
над ним — молча показанная часть выглядела бы как всё.
|
||||||
|
|
||||||
|
### Взял в долг
|
||||||
|
|
||||||
|
В форме продажи выбор рассчёта явный, тремя переключателями:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Заплатил полностью** | по умолчанию, сумма подставляется сама |
|
||||||
|
| **Взял в долг целиком** | получено ноль, вся сумма уходит в долг |
|
||||||
|
| **Заплатил часть** | сколько дал — в поле, остаток в долг |
|
||||||
|
|
||||||
|
Раньше это было просто денежное поле, заполненное полной суммой: долг
|
||||||
|
получался, если догадаться стереть число и вписать ноль. Хуже того, любая
|
||||||
|
последующая правка строк подставляла полную оплату обратно — выставленный долг
|
||||||
|
исчезал молча, а подсказка продолжала уверять, что оплачено полностью.
|
||||||
|
Теперь пересчёт работает только в режиме «заплатил полностью».
|
||||||
|
|
||||||
|
Поле суммы остаётся живым: вписал число руками — загорится подходящий
|
||||||
|
переключатель. Ноль включает «в долг целиком», часть суммы — «заплатил часть»,
|
||||||
|
больше суммы — разница уходит в чаевые.
|
||||||
|
|
||||||
|
**Долг без имени записать нельзя.** Такая запись невосстановима: на экране
|
||||||
|
долгов она попадает в кучу «без контрагента», и с кого спрашивать деньги —
|
||||||
|
уже не узнать. Форма продажи не выпустит такую запись и подсветит подсказку;
|
||||||
|
контрагента можно завести кнопкой рядом с полем «Кому». В быстром вводе это
|
||||||
|
предупреждение, а не запрет: внося историю, можно и правда не помнить, кто это
|
||||||
|
был.
|
||||||
|
|
||||||
|
На экране долгов можно выбрать **человека целиком** и рассчитаться сразу за
|
||||||
|
всё: деньги приходят одной суммой, а гасятся долги по очереди, начиная со
|
||||||
|
старых. Отдал больше, чем был должен, — излишек становится чаевыми, как и
|
||||||
|
переплата по одной продаже. Выбранная отдельная продажа по-прежнему гасится
|
||||||
|
сама по себе.
|
||||||
|
|
||||||
### Отбор продаж
|
### Отбор продаж
|
||||||
|
|
||||||
Список продаж растёт быстрее всех остальных, поэтому у него своя панель
|
Список продаж растёт быстрее всех остальных, поэтому у него своя панель
|
||||||
@ -196,6 +229,19 @@
|
|||||||
так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры
|
так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры
|
||||||
в старых базах не поехали.
|
в старых базах не поехали.
|
||||||
|
|
||||||
|
**Цены фасовок меняются там же, где цены товара** — кнопкой «Цены и история»,
|
||||||
|
и попадают в ту же историю. Дорожают они обычно одним решением поставщика, и
|
||||||
|
разводить их по разным окнам значило бы, что у товара история есть, а у коробки
|
||||||
|
нет. В карточке товара (кнопка «Изменить») цены фасовок показаны, но не
|
||||||
|
редактируются: иначе цена коробки менялась бы из двух мест, и из одного — молча.
|
||||||
|
Там правится только состав: название и размер.
|
||||||
|
|
||||||
|
Стартовая цена новой фасовки тоже попадает в историю — отдельной точкой
|
||||||
|
«заведена фасовка «коробка»» либо, если товар только что создан, прямо в его
|
||||||
|
точку создания. Приписывать коробке цену задним числом программа не станет:
|
||||||
|
у снимков, сделанных до её появления, фасовок нет, и пустое место честнее
|
||||||
|
выдуманных цифр.
|
||||||
|
|
||||||
Если один товар попал в партию и коробками, и поштучно, себестоимость базовой
|
Если один товар попал в партию и коробками, и поштучно, себестоимость базовой
|
||||||
единицы становится средневзвешенной: остаток по товару всё равно один, и при
|
единицы становится средневзвешенной: остаток по товару всё равно один, и при
|
||||||
поштучной продаже иначе было бы непонятно, какая из двух цен списывается.
|
поштучной продаже иначе было бы непонятно, какая из двух цен списывается.
|
||||||
|
|||||||
148
app/journal.py
148
app/journal.py
@ -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,20 +390,58 @@ def set_packs(vault, product_id: str, packs: list[dict]) -> Product:
|
|||||||
|
|
||||||
# Обе цены — за фасовку целиком. Ноль значит «своей цены нет,
|
# Обе цены — за фасовку целиком. Ноль значит «своей цены нет,
|
||||||
# считай от цены базовой единицы».
|
# считай от цены базовой единицы».
|
||||||
retail = m.money(raw.get("retail_price"))
|
existing = product.pack(name)
|
||||||
cost = m.money(raw.get("cost_price"))
|
if existing is not None:
|
||||||
if retail < 0 or cost < 0:
|
retail, cost = existing.retail_price, existing.cost_price
|
||||||
raise ValidationError("Цена фасовки не может быть отрицательной.")
|
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))
|
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
|
||||||
|
|||||||
@ -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],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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(
|
# Долг без имени взыскать не с кого: на экране долгов он
|
||||||
f"В долг уйдёт {m.fmt_money(debt, currency)}."
|
# окажется в куче «без контрагента» и там и останется.
|
||||||
if debt > 0
|
parts.append(
|
||||||
else "Оплачено полностью."
|
f"В долг уйдёт {m.fmt_money(debt, currency)} — "
|
||||||
)
|
"укажи, кому, иначе будет непонятно, с кого спрашивать."
|
||||||
|
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
|
||||||
|
|||||||
@ -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:
|
||||||
"""Записать собранные строки как отдельные продажи.
|
"""Записать собранные строки как отдельные продажи.
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
# --- чаевые ---------------------------------------------------------------
|
# --- чаевые ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user