Модель кассы есть: товары, строки и корзины. Теперь — правила: какую скидку дать, как принять оплату,
как напечатать чек и посчитать выручку за день. Каждое правило — отдельный класс или иерархия классов, а
касса Register собирает их вместе композицией.
| Часть кассы | Что это | Инструмент |
|---|---|---|
| скидки | варианты одного поведения | абстрактный класс + подклассы, полиморфизм |
| оплата | варианты одного поведения, свои ошибки | абстрактный класс, PaymentError |
| чек | данные и текст для печати | dataclass, свойство, __str__ |
| касса | всё вместе | композиция, max(..., key=...), исключения |
Что у нас уже есть
Упрощённая модель из первой части: корзина сразу получает строки (каталог здесь не нужен), но умеет то же,
что ваша, — subtotal, len и цикл по строкам. Готов и «договор» для оплаты — абстрактный PaymentMethod
с наличными Cash, как в мини-проекте модуля 6:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
class ShopError(Exception):
pass
@dataclass(frozen=True)
class Product:
name: str
price: int
category: str
@dataclass
class Line:
product: Product
qty: int
@property
def cost(self):
return self.product.price * self.qty
@dataclass
class Cart:
lines: list = field(default_factory=list)
def __len__(self):
return len(self.lines)
def __iter__(self):
return iter(self.lines)
@property
def subtotal(self):
return sum(line.cost for line in self.lines)
class PaymentError(ShopError):
pass
class PaymentMethod(ABC):
@abstractmethod
def pay(self, amount):
...
class Cash(PaymentMethod):
def __init__(self, given):
self.given = given
def pay(self, amount):
if self.given < amount:
raise PaymentError("недостаточно наличных")
return f"наличные {self.given} ₽, сдача {self.given - amount} ₽"
latte = Product("Латте", 240, "кофе")
croissant = Product("Круассан", 140, "выпечка")
muffin = Product("Маффин", 130, "выпечка")
tea = Product("Чай", 120, "чай")
raf = Product("Раф", 260, "кофе")
sample = Cart([Line(latte, 3), Line(croissant, 2)])
print(sample.subtotal, len(sample), [line.cost for line in sample])
print(Cash(1000).pay(900))1000 2 [720, 280] наличные 1000 ₽, сдача 100 ₽
Шаг 1. Скидки
Объявите абстрактный класс Discount: __init__(self, title) записывает название акции в title,
str(discount) возвращает его, абстрактный метод amount(cart) — сколько рублей скидка даёт корзине.
class Discount(ABC): и @abstractmethod над amount.Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
class Discount:
def __init__(self, title):
...
def __str__(self):
...
def amount(self, cart):
...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
class Discount(ABC):
def __init__(self, title):
self.title = title
def __str__(self):
return self.title
@abstractmethod
def amount(self, cart):
...Объявите два подкласса Discount. PercentDiscount(10) — название "скидка 10 %", скидка — 10 % от
subtotal корзины. CategoryDiscount("выпечка", 20) — название "выпечка -20 %", скидка — 20 % от стоимости
только тех строк корзины, у товара которых такая категория. Обе скидки округляются до целых.
super().__init__(…).for line in cart; сложите cost тех, у товара которых нужная категория.Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
class PercentDiscount(Discount):
def __init__(self, percent):
...
def amount(self, cart):
...
class CategoryDiscount(Discount):
def __init__(self, category, percent):
...
def amount(self, cart):
...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
class PercentDiscount(Discount):
def __init__(self, percent):
super().__init__(f"скидка {percent} %")
self.percent = percent
def amount(self, cart):
return round(cart.subtotal * self.percent / 100)
class CategoryDiscount(Discount):
def __init__(self, category, percent):
super().__init__(f"{category} -{percent} %")
self.category = category
self.percent = percent
def amount(self, cart):
base = sum(line.cost for line in cart if line.product.category == self.category)
return round(base * self.percent / 100)Шаг 2. Оплата
Наличные Cash уже есть. Объявите второй способ оплаты — подкласс Card класса PaymentMethod:
Card(2000) хранит остаток на счёте в balance, pay(amount) списывает сумму и возвращает
"карта: 664 ₽", а если денег меньше — выбрасывает PaymentError("недостаточно средств"), и баланс не
меняется.
Cash из демонстрации: проверка с raise, потом изменение баланса и строка для чека.Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
class Card(PaymentMethod):
def __init__(self, balance):
...
def pay(self, amount):
...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
class Card(PaymentMethod):
def __init__(self, balance):
self.balance = balance
def pay(self, amount):
if amount > self.balance:
raise PaymentError("недостаточно средств")
self.balance -= amount
return f"карта: {amount} ₽"Шаг 3. Чек
В заготовке — dataclass Receipt с полями number, subtotal, discount (название акции), saved (скидка
в рублях), payment (строка оплаты) и свойством total. Допишите __str__: текст чека — пять строк:
Чек №1
Сумма: 1000 ₽
Скидка «скидка 10 %»: -100 ₽
К оплате: 900 ₽
Оплата: наличные 1000 ₽, сдача 100 ₽"\n".join(lines).Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
@dataclass
class Receipt:
number: int
subtotal: int
discount: str
saved: int
payment: str
@property
def total(self):
return self.subtotal - self.saved
def __str__(self):
...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
@dataclass
class Receipt:
number: int
subtotal: int
discount: str
saved: int
payment: str
@property
def total(self):
return self.subtotal - self.saved
def __str__(self):
lines = [
f"Чек №{self.number}",
f"Сумма: {self.subtotal} ₽",
f"Скидка «{self.discount}»: -{self.saved} ₽",
f"К оплате: {self.total} ₽",
f"Оплата: {self.payment}",
]
return "\n".join(lines)Шаг 4. Касса
Объявите класс Register: Register([скидка, скидка, …]) хранит доступные скидки и список выданных чеков
receipts (у новой кассы — пустой).
best_discount(cart)— скидка, которая даёт корзине больше всего рублей;checkout(cart, method)— провести покупку: пустая корзина (subtotalравен 0) —ShopError("пустая корзина"); иначе выбрать лучшую скидку, оплатитьmethodсумму к оплате (ошибка оплаты уходит дальше, чек не создаётся), создатьReceiptсо следующим номером (1, 2, 3… по выданным чекам), сохранить его вreceiptsи вернуть.
len(self.receipts) + 1, и его берут только после успешной оплаты.checkout: проверка корзины → скидка → payment = method.pay(total) → чек.Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
class Register:
def __init__(self, discounts):
...
def best_discount(self, cart):
...
def checkout(self, cart, method):
...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
class Register:
def __init__(self, discounts):
self.discounts = list(discounts)
self.receipts = []
def best_discount(self, cart):
return max(self.discounts, key=lambda d: d.amount(cart))
def checkout(self, cart, method):
if cart.subtotal == 0:
raise ShopError("пустая корзина")
discount = self.best_discount(cart)
saved = discount.amount(cart)
payment = method.pay(cart.subtotal - saved)
receipt = Receipt(len(self.receipts) + 1, cart.subtotal, str(discount), saved, payment)
self.receipts.append(receipt)
return receiptШаг 5. День на кассе
Касса работает со скидками PercentDiscount(10) и CategoryDiscount("выпечка", 20). Проведите покупки
из queue (пары «корзина, способ оплаты») через одну кассу register. Ошибки кассы перехватывайте и
считайте: failed — сколько покупок не прошло. Сохраните в revenue выручку — сумму total всех чеков,
а в biggest — номер чека с наибольшей суммой к оплате.
try: register.checkout(cart, method) / except ShopError: failed += 1.max(register.receipts, key=lambda r: r.total).number.Заготовка упражнения обновилась с тех пор, как вы сохранили свой код.
Вернуть заготовку? Ваш код в этом упражнении будет удалён.
card = Card(2000)
queue = [
(Cart([Line(latte, 3), Line(croissant, 2)]), Cash(1000)),
(Cart([Line(croissant, 3), Line(muffin, 2), Line(tea, 1)]), card),
(Cart([Line(raf, 1)]), Cash(200)),
(Cart(), Cash(500)),
(Cart([Line(tea, 2), Line(muffin, 1)]), card),
]
register = ...
failed = ...
revenue = ...
biggest = ...Упражнение пока не решено, поэтому для ячеек ниже выполнено эталонное решение — ваш код не изменён.
card = Card(2000)
queue = [
(Cart([Line(latte, 3), Line(croissant, 2)]), Cash(1000)),
(Cart([Line(croissant, 3), Line(muffin, 2), Line(tea, 1)]), card),
(Cart([Line(raf, 1)]), Cash(200)),
(Cart(), Cash(500)),
(Cart([Line(tea, 2), Line(muffin, 1)]), card),
]
register = Register([PercentDiscount(10), CategoryDiscount("выпечка", 20)])
failed = 0
for cart, method in queue:
try:
register.checkout(cart, method)
except ShopError:
failed += 1
revenue = sum(r.total for r in register.receipts)
biggest = max(register.receipts, key=lambda r: r.total).numberИтог
for receipt in register.receipts:
print(receipt)
print()
print(f"не прошло покупок: {failed}; выручка: {revenue} ₽; самый большой чек — №{biggest}")Чек №1 Сумма: 1000 ₽ Скидка «скидка 10 %»: -100 ₽ К оплате: 900 ₽ Оплата: наличные 1000 ₽, сдача 100 ₽ Чек №2 Сумма: 800 ₽ Скидка «выпечка -20 %»: -136 ₽ К оплате: 664 ₽ Оплата: карта: 664 ₽ Чек №3 Сумма: 370 ₽ Скидка «скидка 10 %»: -37 ₽ К оплате: 333 ₽ Оплата: карта: 333 ₽ не прошло покупок: 2; выручка: 1897 ₽; самый большой чек — №1
Из пяти покупок прошли три: в первой лучшей оказалась скидка 10 % (100 ₽ против 56 ₽ на выпечку), во второй — скидка на выпечку (136 ₽). Покупка рафа не прошла — 200 ₽ наличными меньше 234 ₽ к оплате, а пустую корзину касса не провела вовсе. Выручка — 1897 ₽.
Что получилось
Касса «Кофейной лавки» — это дюжина небольших классов, и каждый отвечает за одно: Product и Line хранят
данные, Cart — строки, скидки и способы оплаты — варианты поведения за общим абстрактным интерфейсом,
Receipt — данные чека и его текст, Register связывает всё вместе. Новая акция или новый способ оплаты — это
новый подкласс; касса и отчёт не меняются. Это и есть цель объектно-ориентированного проектирования.
Что дальше
Курс закончен, но у ООП в Python есть куда расти. Темы в порядке пользы:
- Аннотации типов и
typing.Protocol. Аннотации можно писать не только в dataclass, но и в любых методах, а программы-проверщики (mypy, pyright) найдут ошибки до запуска.Protocolописывает «утиный» интерфейс без наследования — как способы доставки из модуля 5. - Тесты. Проверки в упражнениях курса — это тесты. В настоящих проектах их пишут с библиотекой pytest:
функции
test_…сassert, как здесь. - Паттерны проектирования. Шаблонный метод, стратегия (скидки и способы оплаты — это она), фабрика (альтернативные конструкторы) — у типовых решений есть имена; книги о паттернах помогают узнавать их в чужом коде.
__slots__,__getattr__, дескрипторы. Какpropertyустроено изнутри и как сэкономить память на миллионе экземпляров. Это продвинутые темы, к ним возвращаются по необходимости.- Классы библиотек. Попробуйте прочитать устройство знакомых объектов:
pathlib.Pathперегружает оператор/(Path("data") / "orders.csv"), аDataFrameв pandas — контейнер с__getitem__,__len__и__iter__. Теперь вы понимаете, почемуdf["price"],len(df)иfor column in dfработают.
Задачи для практики по всем темам курса появятся в разделе «Задачи», а справочник по ООП — во вкладке «Справочник».