Своя закупочная цена у фасовки
Коробка печенья из 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 всегда считаются в базовых единицах, поэтому
|
Остатки, себестоимость и FIFO всегда считаются в базовых единицах, поэтому
|
||||||
поштучные продажи корректно вычитаются из купленных пачек. Количество и цена
|
поштучные продажи корректно вычитаются из купленных коробок. Количество и цена
|
||||||
при этом вводятся в той фасовке, которую выбрал ты: «2 пачки по 200 ₽» так
|
при этом вводятся в той фасовке, которую выбрал ты: «2 коробки по 300 ₽» так
|
||||||
и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь
|
и остаётся в документе. Размер фасовки сохраняется слепком — переопределишь
|
||||||
пачку с 10 на 12 штук, и уже записанные документы не поедут.
|
коробку с 20 на 24 штуки, и уже записанные документы не поедут.
|
||||||
|
|
||||||
**Себестоимость указывается за базовую единицу**, а не за фасовку. Пачка
|
**У фасовки обе цены свои и обе за упаковку целиком.** Коробка из 20 штук,
|
||||||
печенья 10 шт за 200 ₽ — значит себестоимость 20 ₽ за штуку; в закупке
|
которые внутри выходят по 15 ₽, стоит в закупке 300 ₽ — так и указывается.
|
||||||
программа сама подставит 200 ₽, когда выберешь пачку. Чтобы это не приходилось
|
Выводить эту сумму из поштучной цены нельзя: получилось бы, что опт стоит
|
||||||
держать в голове, карточка товара показывает результат прямо под таблицей
|
столько же, сколько розница, и в каждой закупке сумму приходилось бы
|
||||||
фасовок: «В закупку подставится: пачка = 10 шт, себестоимость 200,00 ₽».
|
исправлять руками.
|
||||||
|
|
||||||
|
Чтобы не держать пересчёт в голове, карточка товара расшифровывает результат
|
||||||
|
прямо под таблицей фасовок:
|
||||||
|
|
||||||
|
```
|
||||||
|
коробка = 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:
|
def _packs_text(doc, product: Product) -> str:
|
||||||
|
"""Фасовки для журнала — с обеими ценами.
|
||||||
|
|
||||||
|
Показываем не то, что записано в поле, а то, что реально подставится
|
||||||
|
в документы: у фасовки без своей цены она выводится из базовой, и в журнале
|
||||||
|
полезнее видеть итоговую сумму, а не ноль.
|
||||||
|
"""
|
||||||
cur = doc.settings.currency
|
cur = doc.settings.currency
|
||||||
return "; ".join(
|
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
|
for p in product.packs
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -329,10 +337,13 @@ def set_packs(vault, product_id: str, packs: list[dict]) -> Product:
|
|||||||
if size <= 0:
|
if size <= 0:
|
||||||
raise ValidationError(f"Размер фасовки «{name}» должен быть больше нуля.")
|
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("Цена фасовки не может быть отрицательной.")
|
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)
|
before = _flat_product(doc, product)
|
||||||
product.packs = built
|
product.packs = built
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from typing import Any
|
|||||||
|
|
||||||
from . import money as m
|
from . import money as m
|
||||||
|
|
||||||
SCHEMA_VERSION = 4
|
SCHEMA_VERSION = 5
|
||||||
|
|
||||||
ONE = Decimal(1)
|
ONE = Decimal(1)
|
||||||
|
|
||||||
@ -98,17 +98,23 @@ class PricePoint:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Pack:
|
class Pack:
|
||||||
"""Фасовка: сколько базовых единиц внутри и почём продаётся целиком.
|
"""Фасовка: сколько базовых единиц внутри, почём берётся и почём отдаётся.
|
||||||
|
|
||||||
Пачка печенья по 10 штук за 200 ₽ и та же печенька поштучно по 30 ₽ —
|
Пачка печенья по 10 штук и та же печенька поштучно — это один товар
|
||||||
это один товар с двумя фасовками, а не два разных товара. Остатки и
|
с двумя фасовками, а не два разных товара. Остатки и себестоимость всегда
|
||||||
себестоимость всегда считаются в базовых единицах, поэтому поштучные
|
считаются в базовых единицах, поэтому поштучные продажи корректно
|
||||||
продажи корректно вычитаются из купленных пачек.
|
вычитаются из купленных пачек.
|
||||||
|
|
||||||
|
**Обе цены — за всю фасовку целиком**, и обе свои, а не производные.
|
||||||
|
Коробку и берут, и отдают по другой цене, чем поштучный товар: ради этого
|
||||||
|
её и берут. Ноль означает «своей цены нет, считай от цены базовой единицы»
|
||||||
|
— так ведут себя все фасовки, заведённые до появления этого поля.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
size: Decimal
|
size: Decimal
|
||||||
retail_price: Decimal = m.ZERO
|
retail_price: Decimal = m.ZERO
|
||||||
|
cost_price: Decimal = m.ZERO
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict) -> "Pack":
|
def from_dict(cls, d: dict) -> "Pack":
|
||||||
@ -116,6 +122,7 @@ class Pack:
|
|||||||
name=d.get("name", ""),
|
name=d.get("name", ""),
|
||||||
size=m.qty(d.get("size", 1)),
|
size=m.qty(d.get("size", 1)),
|
||||||
retail_price=m.money(d.get("retail_price")),
|
retail_price=m.money(d.get("retail_price")),
|
||||||
|
cost_price=m.money(d.get("cost_price")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
@ -123,6 +130,7 @@ class Pack:
|
|||||||
"name": self.name,
|
"name": self.name,
|
||||||
"size": m.dumps(self.size),
|
"size": m.dumps(self.size),
|
||||||
"retail_price": m.dumps(self.retail_price),
|
"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
|
return pack.size if pack else ONE
|
||||||
|
|
||||||
def cost_for(self, uom: str) -> Decimal:
|
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:
|
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)
|
pack = self.pack(uom)
|
||||||
if pack is None:
|
if pack is None:
|
||||||
return self.retail_price
|
return base
|
||||||
if pack.retail_price > 0:
|
own = getattr(pack, attr)
|
||||||
return pack.retail_price
|
if own > 0:
|
||||||
return m.money(self.retail_price * pack.size)
|
return own
|
||||||
|
return m.money(base * pack.size)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict) -> "Product":
|
def from_dict(cls, d: dict) -> "Product":
|
||||||
|
|||||||
@ -93,11 +93,28 @@ def _migrate_3_to_4(raw: dict) -> dict:
|
|||||||
return raw
|
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 = {
|
_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -22,34 +22,80 @@ from . import theme
|
|||||||
from . import widgets as w
|
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):
|
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)
|
super().__init__(parent)
|
||||||
self.currency = currency
|
self.currency = currency
|
||||||
self.base_unit = base_unit
|
self.base_unit = base_unit
|
||||||
# Откуда брать себестоимость базовой единицы для подсказки.
|
# Откуда брать цены базовой единицы: по ним считается фасовка,
|
||||||
|
# у которой своей цены нет.
|
||||||
self.cost_source = cost_source or (lambda: m.ZERO)
|
self.cost_source = cost_source or (lambda: m.ZERO)
|
||||||
|
self.retail_source = retail_source or (lambda: m.ZERO)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(6)
|
||||||
|
|
||||||
self.table = QTableWidget(0, 3)
|
self.table = QTableWidget(0, 4)
|
||||||
self.table.setHorizontalHeaderLabels(["Название", f"Сколько {base_unit}", "Цена за фасовку"])
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
["Название", f"Сколько {base_unit}", "Закупка за фасовку", "Продажа за фасовку"]
|
||||||
|
)
|
||||||
self.table.verticalHeader().setVisible(False)
|
self.table.verticalHeader().setVisible(False)
|
||||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch)
|
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)
|
header.setSectionResizeMode(column, QHeaderView.Interactive)
|
||||||
self.table.setColumnWidth(column, width)
|
self.table.setColumnWidth(column, width)
|
||||||
self.table.setMaximumHeight(170)
|
self.table.setMaximumHeight(170)
|
||||||
@ -63,34 +109,37 @@ class PacksEditor(QWidget):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Себестоимость фасовки нигде не вводится — она выводится из цены
|
# Показываем результат построчно: иначе непонятно, откуда берётся
|
||||||
# базовой единицы. Показываем результат, иначе непонятно, откуда
|
# сумма, которую программа подставит в документ, и легко перепутать
|
||||||
# берётся сумма, которую программа подставит в закупку.
|
# цену за упаковку с ценой за штуку.
|
||||||
self.derived = w.label("", "dim")
|
self.derived = w.label("", "dim")
|
||||||
self.derived.setWordWrap(True)
|
self.derived.setWordWrap(True)
|
||||||
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)
|
self.add_pack(pack.name, pack.size, pack.retail_price, pack.cost_price)
|
||||||
self.refresh_hint()
|
self.refresh_hint()
|
||||||
|
|
||||||
def refresh_hint(self) -> None:
|
def refresh_hint(self) -> None:
|
||||||
cost = m.money(self.cost_source())
|
packs = self.packs()
|
||||||
parts = []
|
if not packs:
|
||||||
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
|
|
||||||
self.derived.setText(
|
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:
|
def set_base_unit(self, unit: str) -> None:
|
||||||
self.base_unit = unit or "шт"
|
self.base_unit = unit or "шт"
|
||||||
@ -99,12 +148,12 @@ class PacksEditor(QWidget):
|
|||||||
)
|
)
|
||||||
self.refresh_hint()
|
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()
|
r = self.table.rowCount()
|
||||||
self.table.insertRow(r)
|
self.table.insertRow(r)
|
||||||
|
|
||||||
name_edit = QLineEdit(name)
|
name_edit = QLineEdit(name)
|
||||||
name_edit.setPlaceholderText("пачка")
|
name_edit.setPlaceholderText("коробка")
|
||||||
name_edit.textChanged.connect(self.refresh_hint)
|
name_edit.textChanged.connect(self.refresh_hint)
|
||||||
self.table.setCellWidget(r, self.COL_NAME, name_edit)
|
self.table.setCellWidget(r, self.COL_NAME, name_edit)
|
||||||
|
|
||||||
@ -113,9 +162,17 @@ class PacksEditor(QWidget):
|
|||||||
size_spin.valueChanged.connect(self.refresh_hint)
|
size_spin.valueChanged.connect(self.refresh_hint)
|
||||||
self.table.setCellWidget(r, self.COL_SIZE, size_spin)
|
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 = w.MoneySpin(self.currency)
|
||||||
price_spin.set_decimal(price)
|
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()
|
self.refresh_hint()
|
||||||
|
|
||||||
def remove_current(self) -> None:
|
def remove_current(self) -> None:
|
||||||
@ -134,7 +191,8 @@ class PacksEditor(QWidget):
|
|||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"size": self.table.cellWidget(r, self.COL_SIZE).value_decimal(),
|
"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
|
return result
|
||||||
@ -147,7 +205,7 @@ class ProductDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.product = product
|
self.product = product
|
||||||
self.setWindowTitle("Товар" if product else "Новый товар")
|
self.setWindowTitle("Товар" if product else "Новый товар")
|
||||||
self.setMinimumWidth(520)
|
self.setMinimumWidth(660)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
@ -178,8 +236,9 @@ class ProductDialog(QDialog):
|
|||||||
layout.addWidget(w.label("Фасовки", "h2"))
|
layout.addWidget(w.label("Фасовки", "h2"))
|
||||||
layout.addWidget(
|
layout.addWidget(
|
||||||
w.label(
|
w.label(
|
||||||
"Если товар берётся упаковками, а продаётся и поштучно — заведи фасовку. "
|
"Если товар берётся упаковкой, а продаётся и поштучно — заведи фасовку. "
|
||||||
"Цены и себестоимость выше указываются за одну базовую единицу.",
|
"Цены выше указываются за одну базовую единицу, у фасовки — за всю "
|
||||||
|
"упаковку целиком: коробка из 20 штук по 15 ₽ стоит 300 ₽.",
|
||||||
"dim",
|
"dim",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -189,19 +248,30 @@ class ProductDialog(QDialog):
|
|||||||
currency,
|
currency,
|
||||||
self.unit.text() or "шт",
|
self.unit.text() or "шт",
|
||||||
product.packs if product else (),
|
product.packs if product else (),
|
||||||
# У существующего товара цена правится отдельным диалогом, у нового
|
# У существующего товара цены правятся отдельным диалогом, у нового
|
||||||
# берём её прямо из поля, чтобы подсказка считалась на лету.
|
# берём их прямо из полей, чтобы подсказка считалась на лету.
|
||||||
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,
|
||||||
)
|
)
|
||||||
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:
|
||||||
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)
|
layout.addWidget(self.packs)
|
||||||
|
|
||||||
if product is not None:
|
if product is not None:
|
||||||
|
# Полей с ценами здесь нет — они правятся через историю. Но фасовка
|
||||||
|
# без своей цены считается именно от них, поэтому показываем.
|
||||||
layout.addWidget(
|
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(
|
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))
|
||||||
)
|
)
|
||||||
@ -314,9 +384,12 @@ class ProductsPage(QWidget):
|
|||||||
|
|
||||||
margin = m.money(product.retail_price - product.cost_price)
|
margin = m.money(product.retail_price - product.cost_price)
|
||||||
left = stock.get(product.id, m.ZERO)
|
left = stock.get(product.id, m.ZERO)
|
||||||
|
# Показываем итоговые суммы, а не содержимое полей: у фасовки без
|
||||||
|
# своей цены в поле ноль, а в документ подставится расчётная сумма.
|
||||||
packs = ", ".join(
|
packs = ", ".join(
|
||||||
f"{p.name} = {m.fmt_qty(p.size)} {product.unit} "
|
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
|
for p in product.packs
|
||||||
)
|
)
|
||||||
rows.append(
|
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, как и покупали
|
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):
|
def test_odd_pack_size_does_not_lose_kopecks(vault, today, in_two_weeks):
|
||||||
"""200 на 3 не делится нацело — округление не должно копиться."""
|
"""200 на 3 не делится нацело — округление не должно копиться."""
|
||||||
product = journal.create_product(vault, "Пирожки", unit="шт", cost_price=0, retail_price=100)
|
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")
|
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():
|
def test_future_schema_refuses_to_open():
|
||||||
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
|
with pytest.raises(crypto.UnsupportedFormat, match="новой версией"):
|
||||||
migrate({"schema_version": 999})
|
migrate({"schema_version": 999})
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -541,11 +541,110 @@ def test_packs_editor_round_trip(window):
|
|||||||
|
|
||||||
dialog = PD("₽", window.vault.doc.product(cookies.id))
|
dialog = PD("₽", window.vault.doc.product(cookies.id))
|
||||||
assert dialog.packs.packs() == [
|
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()
|
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):
|
def test_lines_editor_add_and_remove(window, buns):
|
||||||
dialog = BatchDialog(window)
|
dialog = BatchDialog(window)
|
||||||
editor = dialog.lines
|
editor = dialog.lines
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user