Своя закупочная цена у фасовки
Коробка печенья из 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:
parent
3968806094
commit
950ccc33b3
39
README.md
39
README.md
@ -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 ₽ ловят ошибку в двадцать раз сразу, а не
|
||||
в закупке.
|
||||
|
||||
Оставленный ноль означает «своей цены нет, считай от цены за одну штуку» —
|
||||
так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры
|
||||
в старых базах не поехали.
|
||||
|
||||
Если один товар попал в партию и коробками, и поштучно, себестоимость базовой
|
||||
единицы становится средневзвешенной: остаток по товару всё равно один, и при
|
||||
поштучной продаже иначе было бы непонятно, какая из двух цен списывается.
|
||||
|
||||
### Ввод истории
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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)}"
|
||||
packs = self.packs()
|
||||
if not packs:
|
||||
self.derived.setText(
|
||||
"Цены указываются за упаковку целиком. Оставишь ноль — "
|
||||
f"посчитается от цены за одну {self.base_unit}."
|
||||
)
|
||||
|
||||
if not parts:
|
||||
self.derived.setText("")
|
||||
return
|
||||
self.derived.setText(
|
||||
"В закупку подставится: " + "; ".join(parts)
|
||||
+ ". Себестоимость фасовки считается от цены за одну "
|
||||
f"{self.base_unit} — её и указывай выше."
|
||||
)
|
||||
|
||||
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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user