Своя закупочная цена у фасовки

Коробка печенья из 20 штук обходится дешевле, чем 20 штук поодиночке —
ради этого её и берут. Своей была только цена продажи, а себестоимость
выводилась умножением поштучной цены на размер фасовки, то есть опт
считался по цене розницы. В каждой закупке сумму приходилось править
руками.

Теперь у фасовки обе цены свои и обе за упаковку целиком. Ноль означает
«своей цены нет, считай от базовой» — прежнее поведение, поэтому цифры
в уже заведённых базах не поехали.

Карточка товара расшифровывает результат построчно: «коробка = 20 шт ·
закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)».
Цена вводится за упаковку, а думает человек о ней поштучно, и без
пересчёта на виду ошибку в размер фасовки замечаешь уже в закупке.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
food-market 2026-08-24 15:46:55 +03:00
parent 3968806094
commit 950ccc33b3
8 changed files with 400 additions and 71 deletions

View File

@ -131,21 +131,40 @@
### Фасовки
Пачка печенья 10 шт за 200 ₽ и та же печенька поштучно за 30 ₽ — это **один
Печенье поштучно за 25 ₽ и коробка того же печенья на 20 штук — это **один
товар с двумя фасовками**, а не два разных товара. У товара есть базовая
единица (штука) и любое число фасовок со своим размером и своей ценой.
единица (штука) и любое число фасовок со своим размером и своими ценами.
Остатки, себестоимость и FIFO всегда считаются в базовых единицах, поэтому
поштучные продажи корректно вычитаются из купленных пачек. Количество и цена
при этом вводятся в той фасовке, которую выбрал ты: «2 пачки по 200 ₽» так
поштучные продажи корректно вычитаются из купленных коробок. Количество и цена
при этом вводятся в той фасовке, которую выбрал ты: «2 коробки по 300 ₽» так
и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь
пачку с 10 на 12 штук, и уже записанные документы не поедут.
коробку с 20 на 24 штуки, и уже записанные документы не поедут.
**Себестоимость указывается за базовую единицу**, а не за фасовку. Пачка
печенья 10 шт за 200 ₽ — значит себестоимость 20 ₽ за штуку; в закупке
программа сама подставит 200 ₽, когда выберешь пачку. Чтобы это не приходилось
держать в голове, карточка товара показывает результат прямо под таблицей
фасовок: «В закупку подставится: пачка = 10 шт, себестоимость 200,00 ₽».
**У фасовки обе цены свои и обе за упаковку целиком.** Коробка из 20 штук,
которые внутри выходят по 15 ₽, стоит в закупке 300 ₽ — так и указывается.
Выводить эту сумму из поштучной цены нельзя: получилось бы, что опт стоит
столько же, сколько розница, и в каждой закупке сумму приходилось бы
исправлять руками.
Чтобы не держать пересчёт в голове, карточка товара расшифровывает результат
прямо под таблицей фасовок:
```
коробка = 20 шт · закупка 300,00 ₽ (15,00 ₽ за шт) · продажа 500,00 ₽ (25,00 ₽ за шт)
```
Цена вводится за упаковку, а думает человек о ней поштучно — «коробка из 20,
они там по 15». Показанные тут же 15 ₽ ловят ошибку в двадцать раз сразу, а не
в закупке.
Оставленный ноль означает «своей цены нет, считай от цены за одну штуку» —
так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры
в старых базах не поехали.
Если один товар попал в партию и коробками, и поштучно, себестоимость базовой
единицы становится средневзвешенной: остаток по товару всё равно один, и при
поштучной продаже иначе было бы непонятно, какая из двух цен списывается.
### Ввод истории

View File

@ -139,9 +139,17 @@ def _lines_text(doc, lines, price_attr: str) -> str:
def _packs_text(doc, product: Product) -> str:
"""Фасовки для журнала — с обеими ценами.
Показываем не то, что записано в поле, а то, что реально подставится
в документы: у фасовки без своей цены она выводится из базовой, и в журнале
полезнее видеть итоговую сумму, а не ноль.
"""
cur = doc.settings.currency
return "; ".join(
f"{p.name} = {m.fmt_qty(p.size)} {product.unit} по {m.fmt_money(p.retail_price, cur)}"
f"{p.name} = {m.fmt_qty(p.size)} {product.unit}, "
f"закупка {m.fmt_money(product.cost_for(p.name), cur)}, "
f"продажа {m.fmt_money(product.retail_for(p.name), cur)}"
for p in product.packs
)
@ -329,10 +337,13 @@ def set_packs(vault, product_id: str, packs: list[dict]) -> Product:
if size <= 0:
raise ValidationError(f"Размер фасовки «{name}» должен быть больше нуля.")
price = m.money(raw.get("retail_price"))
if price < 0:
# Обе цены — за фасовку целиком. Ноль значит «своей цены нет,
# считай от цены базовой единицы».
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=price))
built.append(Pack(name=name, size=size, retail_price=retail, cost_price=cost))
before = _flat_product(doc, product)
product.packs = built

View File

@ -15,7 +15,7 @@ from typing import Any
from . import money as m
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
ONE = Decimal(1)
@ -98,17 +98,23 @@ class PricePoint:
@dataclass
class Pack:
"""Фасовка: сколько базовых единиц внутри и почём продаётся целиком.
"""Фасовка: сколько базовых единиц внутри, почём берётся и почём отдаётся.
Пачка печенья по 10 штук за 200 и та же печенька поштучно по 30
это один товар с двумя фасовками, а не два разных товара. Остатки и
себестоимость всегда считаются в базовых единицах, поэтому поштучные
продажи корректно вычитаются из купленных пачек.
Пачка печенья по 10 штук и та же печенька поштучно это один товар
с двумя фасовками, а не два разных товара. Остатки и себестоимость всегда
считаются в базовых единицах, поэтому поштучные продажи корректно
вычитаются из купленных пачек.
**Обе цены за всю фасовку целиком**, и обе свои, а не производные.
Коробку и берут, и отдают по другой цене, чем поштучный товар: ради этого
её и берут. Ноль означает «своей цены нет, считай от цены базовой единицы»
так ведут себя все фасовки, заведённые до появления этого поля.
"""
name: str
size: Decimal
retail_price: Decimal = m.ZERO
cost_price: Decimal = m.ZERO
@classmethod
def from_dict(cls, d: dict) -> "Pack":
@ -116,6 +122,7 @@ class Pack:
name=d.get("name", ""),
size=m.qty(d.get("size", 1)),
retail_price=m.money(d.get("retail_price")),
cost_price=m.money(d.get("cost_price")),
)
def to_dict(self) -> dict:
@ -123,6 +130,7 @@ class Pack:
"name": self.name,
"size": m.dumps(self.size),
"retail_price": m.dumps(self.retail_price),
"cost_price": m.dumps(self.cost_price),
}
@ -155,21 +163,28 @@ class Product:
return pack.size if pack else ONE
def cost_for(self, uom: str) -> Decimal:
"""Себестоимость одной штуки выбранной фасовки."""
return m.money(self.cost_price * self.size_of(uom))
"""Себестоимость одной штуки выбранной фасовки.
У фасовки закупочная цена своя: коробку почти всегда отдают дешевле,
чем те же штуки поодиночке, ради этого её и берут. Выводить её
умножением поштучной цены на размер нельзя: получилось бы, что опт
стоит столько же, сколько розница.
"""
return self._pack_price(uom, "cost_price", self.cost_price)
def retail_for(self, uom: str) -> Decimal:
"""Цена продажи одной штуки выбранной фасовки.
"""Цена продажи одной штуки выбранной фасовки."""
return self._pack_price(uom, "retail_price", self.retail_price)
У фасовки цена своя: пачка обычно дешевле, чем те же штуки поодиночке.
Если цена не задана, считаем её от базовой.
"""
def _pack_price(self, uom: str, attr: str, base: Decimal) -> Decimal:
"""Цена за фасовку целиком: своя, а если не задана — от базовой."""
pack = self.pack(uom)
if pack is None:
return self.retail_price
if pack.retail_price > 0:
return pack.retail_price
return m.money(self.retail_price * pack.size)
return base
own = getattr(pack, attr)
if own > 0:
return own
return m.money(base * pack.size)
@classmethod
def from_dict(cls, d: dict) -> "Product":

View File

@ -93,11 +93,28 @@ def _migrate_3_to_4(raw: dict) -> dict:
return raw
def _migrate_4_to_5(raw: dict) -> dict:
"""У фасовки появилась своя закупочная цена.
Раньше себестоимость фасовки выводилась умножением поштучной цены на её
размер, то есть опт стоил ровно столько же, сколько розница. Ноль здесь
означает «своей цены нет, считай по-старому», поэтому цифры уже заведённых
товаров не меняются: пока цену не проставят руками, всё работает как было.
"""
for product in raw.get("products", []):
for pack in product.get("packs", []):
pack.setdefault("cost_price", "0")
raw["schema_version"] = 5
return raw
_MIGRATIONS = {
0: _migrate_0_to_1,
1: _migrate_1_to_2,
2: _migrate_2_to_3,
3: _migrate_3_to_4,
4: _migrate_4_to_5,
}

View File

@ -22,34 +22,80 @@ from . import theme
from . import widgets as w
def pack_hint(pack: dict, base_unit: str, base_cost, base_retail, currency: str) -> str:
"""Одна строка расшифровки фасовки: что подставится и сколько это за штуку.
Цены у фасовки вводятся за упаковку целиком, а думает человек о ней
поштучно «коробка из 20, они там по 15». Пересчёт показываем сразу,
иначе ошибку в двадцать раз замечаешь уже в закупке.
"""
size = m.qty(pack.get("size"))
name = pack.get("name") or "фасовка"
parts = [f"{name} = {m.fmt_qty(size)} {base_unit}"]
for title, own, base in (
("закупка", m.money(pack.get("cost_price")), m.money(base_cost)),
("продажа", m.money(pack.get("retail_price")), m.money(base_retail)),
):
total = own if own > 0 else m.money(base * size)
if total <= 0:
parts.append(f"{title} не задана")
continue
per_unit = m.money(total / size) if size > 0 else m.ZERO
source = "" if own > 0 else ", от цены товара"
parts.append(
f"{title} {m.fmt_money(total, currency)} "
f"({m.fmt_money(per_unit, currency)} за {base_unit}{source})"
)
return " · ".join(parts)
class PacksEditor(QWidget):
"""Фасовки товара: как называется, сколько базовых единиц внутри и почём.
Пачка печенья 10 шт за 200 и та же печенька поштучно за 30 это один
товар с двумя фасовками. Остатки при этом считаются в штуках, поэтому
поштучные продажи вычитаются из купленных пачек.
Пачка печенья 10 шт и та же печенька поштучно это один товар с двумя
фасовками. Остатки при этом считаются в штуках, поэтому поштучные продажи
вычитаются из купленных пачек.
Обе цены вводятся **за упаковку целиком** и обе свои. Раньше своя была
только цена продажи, а себестоимость выводилась умножением поштучной цены
на размер то есть коробка из двадцати штук обходилась ровно как двадцать
штук поодиночке. Опт так не работает, и в закупке эту сумму приходилось
исправлять руками каждый раз.
"""
COL_NAME, COL_SIZE, COL_PRICE = range(3)
COL_NAME, COL_SIZE, COL_COST, COL_RETAIL = range(4)
def __init__(self, currency: str, base_unit: str, packs=(), cost_source=None, parent=None):
def __init__(
self,
currency: str,
base_unit: str,
packs=(),
cost_source=None,
retail_source=None,
parent=None,
):
super().__init__(parent)
self.currency = currency
self.base_unit = base_unit
# Откуда брать себестоимость базовой единицы для подсказки.
# Откуда брать цены базовой единицы: по ним считается фасовка,
# у которой своей цены нет.
self.cost_source = cost_source or (lambda: m.ZERO)
self.retail_source = retail_source or (lambda: m.ZERO)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self.table = QTableWidget(0, 3)
self.table.setHorizontalHeaderLabels(["Название", f"Сколько {base_unit}", "Цена за фасовку"])
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(
["Название", f"Сколько {base_unit}", "Закупка за фасовку", "Продажа за фасовку"]
)
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(36)
header = self.table.horizontalHeader()
header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch)
for column, width in ((self.COL_SIZE, 130), (self.COL_PRICE, 145)):
for column, width in ((self.COL_SIZE, 120), (self.COL_COST, 150), (self.COL_RETAIL, 150)):
header.setSectionResizeMode(column, QHeaderView.Interactive)
self.table.setColumnWidth(column, width)
self.table.setMaximumHeight(170)
@ -63,34 +109,37 @@ class PacksEditor(QWidget):
)
)
# Себестоимость фасовки нигде не вводится — она выводится из цены
# базовой единицы. Показываем результат, иначе непонятно, откуда
# берётся сумма, которую программа подставит в закупку.
# Показываем результат построчно: иначе непонятно, откуда берётся
# сумма, которую программа подставит в документ, и легко перепутать
# цену за упаковку с ценой за штуку.
self.derived = w.label("", "dim")
self.derived.setWordWrap(True)
layout.addWidget(self.derived)
for pack in packs:
self.add_pack(pack.name, pack.size, pack.retail_price)
self.add_pack(pack.name, pack.size, pack.retail_price, pack.cost_price)
self.refresh_hint()
def refresh_hint(self) -> None:
cost = m.money(self.cost_source())
parts = []
for pack in self.packs():
parts.append(
f"{pack['name']} = {m.fmt_qty(pack['size'])} {self.base_unit}, "
f"себестоимость {m.fmt_money(cost * pack['size'], self.currency)}"
)
if not parts:
self.derived.setText("")
return
packs = self.packs()
if not packs:
self.derived.setText(
"В закупку подставится: " + "; ".join(parts)
+ ". Себестоимость фасовки считается от цены за одну "
f"{self.base_unit} — её и указывай выше."
"Цены указываются за упаковку целиком. Оставишь ноль — "
f"посчитается от цены за одну {self.base_unit}."
)
return
lines = [
pack_hint(
pack,
self.base_unit,
self.cost_source(),
self.retail_source(),
self.currency,
)
for pack in packs
]
self.derived.setText("В документы подставится:\n" + "\n".join(lines))
def set_base_unit(self, unit: str) -> None:
self.base_unit = unit or "шт"
@ -99,12 +148,12 @@ class PacksEditor(QWidget):
)
self.refresh_hint()
def add_pack(self, name: str = "", size=1, price=0) -> None:
def add_pack(self, name: str = "", size=1, price=0, cost=0) -> None:
r = self.table.rowCount()
self.table.insertRow(r)
name_edit = QLineEdit(name)
name_edit.setPlaceholderText("пачка")
name_edit.setPlaceholderText("коробка")
name_edit.textChanged.connect(self.refresh_hint)
self.table.setCellWidget(r, self.COL_NAME, name_edit)
@ -113,9 +162,17 @@ class PacksEditor(QWidget):
size_spin.valueChanged.connect(self.refresh_hint)
self.table.setCellWidget(r, self.COL_SIZE, size_spin)
# Обе цены пересчитывают подсказку: без этого правишь сумму, а
# расшифровка «столько-то за штуку» продолжает показывать старую.
cost_spin = w.MoneySpin(self.currency)
cost_spin.set_decimal(cost)
cost_spin.valueChanged.connect(self.refresh_hint)
self.table.setCellWidget(r, self.COL_COST, cost_spin)
price_spin = w.MoneySpin(self.currency)
price_spin.set_decimal(price)
self.table.setCellWidget(r, self.COL_PRICE, price_spin)
price_spin.valueChanged.connect(self.refresh_hint)
self.table.setCellWidget(r, self.COL_RETAIL, price_spin)
self.refresh_hint()
def remove_current(self) -> None:
@ -134,7 +191,8 @@ class PacksEditor(QWidget):
{
"name": name,
"size": self.table.cellWidget(r, self.COL_SIZE).value_decimal(),
"retail_price": self.table.cellWidget(r, self.COL_PRICE).value_decimal(),
"cost_price": self.table.cellWidget(r, self.COL_COST).value_decimal(),
"retail_price": self.table.cellWidget(r, self.COL_RETAIL).value_decimal(),
}
)
return result
@ -147,7 +205,7 @@ class ProductDialog(QDialog):
super().__init__(parent)
self.product = product
self.setWindowTitle("Товар" if product else "Новый товар")
self.setMinimumWidth(520)
self.setMinimumWidth(660)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
@ -178,8 +236,9 @@ class ProductDialog(QDialog):
layout.addWidget(w.label("Фасовки", "h2"))
layout.addWidget(
w.label(
"Если товар берётся упаковками, а продаётся и поштучно — заведи фасовку. "
"Цены и себестоимость выше указываются за одну базовую единицу.",
"Если товар берётся упаковкой, а продаётся и поштучно — заведи фасовку. "
"Цены выше указываются за одну базовую единицу, у фасовки — за всю "
"упаковку целиком: коробка из 20 штук по 15 ₽ стоит 300 ₽.",
"dim",
)
)
@ -189,19 +248,30 @@ class ProductDialog(QDialog):
currency,
self.unit.text() or "шт",
product.packs if product else (),
# У существующего товара цена правится отдельным диалогом, у нового
# берём её прямо из поля, чтобы подсказка считалась на лету.
# У существующего товара цены правятся отдельным диалогом, у нового
# берём их прямо из полей, чтобы подсказка считалась на лету.
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,
)
self.unit.textChanged.connect(self.packs.set_base_unit)
if product is None:
self.cost.valueChanged.connect(lambda _: self.packs.refresh_hint())
for field in (self.cost, self.retail):
field.valueChanged.connect(lambda _: self.packs.refresh_hint())
layout.addWidget(self.packs)
if product is not None:
# Полей с ценами здесь нет — они правятся через историю. Но фасовка
# без своей цены считается именно от них, поэтому показываем.
layout.addWidget(
w.label("Цены меняются отдельной кнопкой — так сохраняется их история.", "dim")
w.label(
f"Цены за одну {product.unit}: закупка "
f"{m.fmt_money(product.cost_price, currency)}, продажа "
f"{m.fmt_money(product.retail_price, currency)}. "
"Меняются кнопкой «Цены и история» — так сохраняется история.",
"dim",
)
)
layout.itemAt(layout.count() - 1).widget().setWordWrap(True)
layout.addWidget(
w.row(None, w.button("Отмена", on_click=self.reject), w.button("Сохранить", "primary", self.accept))
)
@ -314,9 +384,12 @@ class ProductsPage(QWidget):
margin = m.money(product.retail_price - product.cost_price)
left = stock.get(product.id, m.ZERO)
# Показываем итоговые суммы, а не содержимое полей: у фасовки без
# своей цены в поле ноль, а в документ подставится расчётная сумма.
packs = ", ".join(
f"{p.name} = {m.fmt_qty(p.size)} {product.unit} "
f"по {m.fmt_money(p.retail_price, currency)}"
f"({m.fmt_money(product.cost_for(p.name), currency)}"
f"{m.fmt_money(product.retail_for(p.name), currency)})"
for p in product.packs
)
rows.append(

View File

@ -401,6 +401,74 @@ def test_pack_size_is_snapshotted(vault, cookies, today, in_two_weeks):
assert report.batches[0].qty_total == Decimal("20.000") # по 10, как и покупали
def test_bulk_pack_is_cheaper_per_piece(vault, today, in_two_weeks):
"""Коробка обходится дешевле поштучного — иначе смысла её брать нет.
Печенье поштучно 25 , коробка из 20 300 , то есть по 15 за штуку.
Продаём поштучно по 25: наценка идёт от 15, а не от 25.
"""
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}],
)
product = vault.doc.product(product.id)
assert product.cost_for("коробка") == Decimal("300.00")
assert product.retail_for("коробка") == Decimal("500.00")
journal.create_batch(
vault, today, in_two_weeks,
[{"product_id": product.id, "qty": 1, "uom": "коробка", "unit_cost": 300}],
)
journal.create_sale(vault, today, KIND_RETAIL, [line(product, 20, 25)])
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("300.00") # долг пекарне — за коробку
assert batch.qty_total == Decimal("20.000")
assert batch.cash_collected == Decimal("500.00") # 20 × 25
assert report.summary.gross_margin == Decimal("200.00") # 20 × (25 15)
def test_mixed_pack_and_single_purchases_average_the_cost(vault, today, in_two_weeks):
"""В одной партии и коробка, и штуки — остаток по товару всё равно один.
Себестоимость базовой единицы становится средневзвешенной, иначе непонятно,
какая из двух цен списывается при поштучной продаже.
"""
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}],
)
product = vault.doc.product(product.id)
journal.create_batch(
vault, today, in_two_weeks,
[
{"product_id": product.id, "qty": 1, "uom": "коробка", "unit_cost": 300},
{"product_id": product.id, "qty": 5, "uom": "шт", "unit_cost": 25},
],
)
report = ledger.build(vault.doc, today)
batch = report.batches[0]
assert batch.cost_total == Decimal("425.00") # 300 + 125
assert batch.qty_total == Decimal("25.000") # 20 + 5
assert batch.stock_cost == Decimal("425.00") # 25 штук по 17 ₽ в среднем
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)
journal.set_packs(vault, product.id, [{"name": "пачка", "size": 10}])
product = vault.doc.product(product.id)
assert product.cost_for("пачка") == Decimal("200.00") # 20 × 10
assert product.retail_for("пачка") == Decimal("300.00") # 30 × 10
def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
"""200 на 3 не делится нацело — округление не должно копиться."""
product = journal.create_product(vault, "Пирожки", unit="шт", cost_price=0, retail_price=100)

View File

@ -155,6 +155,33 @@ def test_migration_adds_packaging_without_changing_behaviour():
assert doc.sales[0].lines[0].base_unit_price == Decimal("30.00")
def test_migration_keeps_derived_pack_cost():
"""У фасовки появилась своя закупочная цена — старые цифры не должны поехать.
Ноль означает «своей цены нет, считай от базовой», то есть ровно то, как
работала программа до появления поля.
"""
raw = migrate(
{
"schema_version": 4,
"products": [
{
"id": "p_1", "name": "Печенье", "unit": "шт",
"cost_price": "20", "retail_price": "30",
"packs": [{"name": "пачка", "size": "10", "retail_price": "200"}],
}
],
}
)
assert raw["schema_version"] == SCHEMA_VERSION
assert raw["products"][0]["packs"][0]["cost_price"] == "0"
product = Document.from_dict(raw).products[0]
assert product.cost_for("пачка") == Decimal("200.00") # 20 × 10, как раньше
assert product.retail_for("пачка") == Decimal("200.00")
def test_future_schema_refuses_to_open():
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
migrate({"schema_version": 999})

View File

@ -6,7 +6,7 @@
"""
import os
from datetime import timedelta
from datetime import date, timedelta
from decimal import Decimal
import pytest
@ -541,11 +541,110 @@ def test_packs_editor_round_trip(window):
dialog = PD("", window.vault.doc.product(cookies.id))
assert dialog.packs.packs() == [
{"name": "пачка", "size": Decimal("10.000"), "retail_price": Decimal("200.00")}
{
"name": "пачка",
"size": Decimal("10.000"),
"cost_price": Decimal("0.00"),
"retail_price": Decimal("200.00"),
}
]
dialog.deleteLater()
def test_pack_keeps_its_own_purchase_price(window):
"""Коробка обходится дешевле, чем те же штуки поодиночке.
Ради этого её и берут. Пока своей закупочной цены у фасовки не было,
программа подставляла в закупку поштучную цену, умноженную на размер,
то есть опт по цене розницы, и сумму приходилось править руками.
"""
from app import journal as j
from app.ui.page_products import ProductDialog as PD
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
j.set_packs(
window.vault,
cookies.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
window.changed()
cookies = window.vault.doc.product(cookies.id)
assert cookies.cost_for("коробка") == Decimal("300.00") # а не 25 × 20
assert cookies.cost_for("шт") == Decimal("25.00")
dialog = BatchDialog(window)
editor = dialog.lines
editor.table.cellWidget(0, editor.COL_PRODUCT).select_product(cookies.id)
editor.table.cellWidget(0, editor.COL_UOM).setCurrentText("коробка")
line = editor.lines("unit_cost")[0]
assert line["uom"] == "коробка"
assert line["unit_cost"] == Decimal("300.00")
# 20 штук по 15 — себестоимость базовой единицы падает вместе с ценой.
j.create_batch(
window.vault,
date.today(),
date.today(),
[{"product_id": cookies.id, "qty": 1, "uom": "коробка", "unit_cost": 300}],
)
batch = window.vault.doc.batches[-1]
assert batch.lines[0].base_unit_cost == Decimal("15")
assert batch.cost_total == Decimal("300.00")
dialog.deleteLater()
def test_pack_hint_spells_out_the_price_per_unit(window):
"""«Коробка из 20, они по 15» — цифру 15 надо показать, а не держать в уме."""
from app.ui.page_products import pack_hint
text = pack_hint(
{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500},
"шт",
base_cost=25,
base_retail=25,
currency="",
)
assert "коробка = 20 шт" in text
assert "закупка 300,00 ₽ (15,00 ₽ за шт)" in text
assert "продажа 500,00 ₽ (25,00 ₽ за шт)" in text
# Ноль в поле — цена считается от базовой, и об этом сказано прямо.
derived = pack_hint(
{"name": "коробка", "size": 20, "cost_price": 0, "retail_price": 0},
"шт",
base_cost=25,
base_retail=25,
currency="",
)
assert "закупка 500,00 ₽ (25,00 ₽ за шт, от цены товара)" in derived
def test_packs_editor_round_trips_both_prices(window):
from app import journal as j
from app.ui.page_products import ProductDialog as PD
cookies = j.create_product(window.vault, "Печенье", unit="шт", cost_price=25, retail_price=25)
j.set_packs(
window.vault,
cookies.id,
[{"name": "коробка", "size": 20, "cost_price": 300, "retail_price": 500}],
)
dialog = PD("", window.vault.doc.product(cookies.id))
assert dialog.packs.packs() == [
{
"name": "коробка",
"size": Decimal("20.000"),
"cost_price": Decimal("300.00"),
"retail_price": Decimal("500.00"),
}
]
assert "15,00 ₽ за шт" in dialog.packs.derived.text()
dialog.deleteLater()
def test_lines_editor_add_and_remove(window, buns):
dialog = BatchDialog(window)
editor = dialog.lines