diff --git a/README.md b/README.md index 8405fb4..e9d7bd9 100644 --- a/README.md +++ b/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 ₽ ловят ошибку в двадцать раз сразу, а не +в закупке. + +Оставленный ноль означает «своей цены нет, считай от цены за одну штуку» — +так ведут себя фасовки, заведённые до появления этого поля, поэтому цифры +в старых базах не поехали. + +Если один товар попал в партию и коробками, и поштучно, себестоимость базовой +единицы становится средневзвешенной: остаток по товару всё равно один, и при +поштучной продаже иначе было бы непонятно, какая из двух цен списывается. ### Ввод истории diff --git a/app/journal.py b/app/journal.py index 9120044..d0b0f47 100644 --- a/app/journal.py +++ b/app/journal.py @@ -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 diff --git a/app/models.py b/app/models.py index 6af7587..a212350 100644 --- a/app/models.py +++ b/app/models.py @@ -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": diff --git a/app/storage.py b/app/storage.py index 257e318..57024fb 100644 --- a/app/storage.py +++ b/app/storage.py @@ -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, } diff --git a/app/ui/page_products.py b/app/ui/page_products.py index 9812507..16d31e1 100644 --- a/app/ui/page_products.py +++ b/app/ui/page_products.py @@ -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( diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 97bf3f3..5296fd5 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -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) diff --git a/tests/test_storage.py b/tests/test_storage.py index dda7c38..e13a464 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -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}) diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 66c82d0..65daf71 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -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