{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Итоговый проект: скидки, оплата и чек\n",
    "\n",
    "Вторая часть кассы: скидки и оплата — иерархии с абстрактным классом, чек — dataclass со __str__, касса содержит скидки, выбирает лучшую и ведёт отчёт за день. И что изучать дальше.\n",
    "\n",
    "Курс ООП в Python · урок `oop-final-rules` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/oop/final-rules).\n",
    "\n",
    "Выполняйте ячейки по порядку (Shift+Enter), начиная с первой — она готовит урок. После каждого упражнения идёт\n",
    "ячейка проверки: она печатает ✓ / ✗ и итог."
   ],
   "id": "intro"
  },
  {
   "cell_type": "code",
   "metadata": {
    "cellView": "form",
    "jupyter": {
     "source_hidden": true
    }
   },
   "source": [
    "# @title Подготовка урока — выполните эту ячейку первой\n",
    "# Проверяет версии пакетов, записывает файлы данных в папку data/ и определяет _check — проверку упражнений.\n",
    "import base64, os, zlib\n",
    "\n",
    "import sys\n",
    "\n",
    "if sys.version_info < (3, 10):\n",
    "    print(f\"Урок написан для Python 3.10 и новее, у вас {sys.version.split()[0]} — часть кода может не работать\")\n",
    "\n",
    "# ─── Проверка упражнений: тот же раннер, что у задач на сайте ───\n",
    "# ─── Запуск тестов ───────────────────────────────────────────────────────────\n",
    "\n",
    "_TIMEOUT = 2.0  # секунд на тест; отдельный тест может задать свой: test_x.timeout = 10\n",
    "\n",
    "\n",
    "def _call_with_timeout(target, seconds: float) -> bool:\n",
    "    \"\"\"Запускает target() в отдельном потоке. Возвращает False, если за seconds он не завершился.\"\"\"\n",
    "    import sys\n",
    "    import threading\n",
    "\n",
    "    if sys.platform == \"emscripten\":  # Python в браузере: потоков нет, таймаут обеспечивает страница\n",
    "        target()\n",
    "        return True\n",
    "    # Большой стек нужен, чтобы глубокая рекурсия давала RecursionError, а не аварийное завершение Python.\n",
    "    for megabytes in (256, 64, 0):\n",
    "        try:\n",
    "            threading.stack_size(megabytes * 1024 * 1024)\n",
    "            worker = threading.Thread(target=target, daemon=True)\n",
    "            worker.start()\n",
    "        except (ValueError, RuntimeError, MemoryError):\n",
    "            continue\n",
    "        worker.join(seconds)\n",
    "        return not worker.is_alive()\n",
    "    target()  # потоки недоступны — запускаем без таймаута\n",
    "    return True\n",
    "\n",
    "\n",
    "def _test_info(fn) -> dict:\n",
    "    \"\"\"Описание теста, его лимит времени и сообщение на случай зависания.\"\"\"\n",
    "    limit = getattr(fn, \"timeout\", _TIMEOUT)\n",
    "    hint = getattr(fn, \"timeout_hint\", \"\")\n",
    "    message = (\n",
    "        f\"тест не завершился за {limit:g} с — возможно, бесконечный цикл или рекурсия: \"\n",
    "        \"проверьте, что границы/аргументы изменяются на каждом шаге\" + (f\". {hint}\" if hint else \"\")\n",
    "    )\n",
    "    return {\"title\": (fn.__doc__ or fn.__name__).strip(), \"timeout\": limit, \"timeout_message\": message}\n",
    "\n",
    "\n",
    "def _error_result(title: str, error: BaseException, user_file: str | None) -> dict:\n",
    "    \"\"\"Результат упавшего теста: статус, сообщение, тип исключения и строка в коде решения.\"\"\"\n",
    "    line = None\n",
    "    if user_file:  # самый глубокий кадр в коде решения; ошибка внутри самих тестов — без номера строки\n",
    "        tb = error.__traceback__\n",
    "        while tb is not None:\n",
    "            if tb.tb_frame.f_code.co_filename == user_file:\n",
    "                line = tb.tb_lineno\n",
    "            tb = tb.tb_next\n",
    "    status, message = \"error\", f\"{type(error).__name__}: {error}\"\n",
    "    if isinstance(error, NotImplementedError):\n",
    "        status, message, line = \"not_written\", \"функция ещё не написана\", None\n",
    "    elif isinstance(error, AssertionError):\n",
    "        status, message = \"failed\", str(error) or \"проверка не прошла\"\n",
    "    elif isinstance(error, RecursionError):\n",
    "        message = \"RecursionError: слишком глубокая рекурсия — проверьте базовый случай и то, что задача уменьшается\"\n",
    "    elif isinstance(error, MemoryError) or (isinstance(error, OverflowError) and \"index-sized\" in str(error)):\n",
    "        message = f\"{type(error).__name__}: не хватило памяти — возможно, создаётся слишком большая структура данных\"\n",
    "    return {\"title\": title, \"status\": status, \"message\": message, \"error_type\": type(error).__name__, \"line\": line}\n",
    "\n",
    "\n",
    "def _run_tests(verbose: bool = True, on_start=None, on_result=None, user_file: str | None = None) -> list[dict]:\n",
    "    \"\"\"Запускает тесты задачи (_TESTS — функции из tests.py) по порядку, каждый — с таймаутом.\n",
    "\n",
    "    Возвращает список результатов: {\"title\", \"status\", \"message\", \"error_type\", \"line\"}, где status —\n",
    "    passed · failed · error · not_written · timeout · not_run. Если тест завис, при verbose=True печатает\n",
    "    итог и завершает процесс: зависший поток остановить нельзя. on_start(i) и on_result(i, результат)\n",
    "    вызываются по мере выполнения; user_file — имя файла с кодом решения для номеров строк в ошибках.\n",
    "    \"\"\"\n",
    "    import os\n",
    "    import sys\n",
    "\n",
    "    if verbose:\n",
    "        try:  # ✓ и кириллица в консоли Windows со старой кодировкой\n",
    "            sys.stdout.reconfigure(encoding=\"utf-8\")\n",
    "        except Exception:  # noqa: BLE001 — reconfigure может не быть (перенаправленный вывод, браузер)\n",
    "            pass\n",
    "\n",
    "    tests = list(_TESTS)\n",
    "    results: list[dict] = []\n",
    "    not_written = hung = False\n",
    "    for index, fn in enumerate(tests):\n",
    "        info = _test_info(fn)\n",
    "        outcome: dict = {}\n",
    "\n",
    "        def target(fn=fn, outcome=outcome):\n",
    "            try:\n",
    "                fn()\n",
    "            except BaseException as e:\n",
    "                outcome[\"error\"] = e\n",
    "\n",
    "        if on_start:\n",
    "            on_start(index)\n",
    "        if not _call_with_timeout(target, info[\"timeout\"]):\n",
    "            results.append({\"title\": info[\"title\"], \"status\": \"timeout\", \"message\": info[\"timeout_message\"], \"error_type\": None, \"line\": None})\n",
    "            for other in tests[index + 1:]:\n",
    "                results.append({\"title\": _test_info(other)[\"title\"], \"status\": \"not_run\", \"message\": \"не запускался: предыдущий тест завис\", \"error_type\": None, \"line\": None})\n",
    "            hung = True\n",
    "            break\n",
    "        error = outcome.get(\"error\")\n",
    "        if error is None:\n",
    "            result = {\"title\": info[\"title\"], \"status\": \"passed\", \"message\": \"\", \"error_type\": None, \"line\": None}\n",
    "        else:\n",
    "            result = _error_result(info[\"title\"], error, user_file)\n",
    "            not_written = not_written or result[\"status\"] == \"not_written\"\n",
    "        results.append(result)\n",
    "        if on_result:\n",
    "            on_result(index, result)\n",
    "\n",
    "    if not_written:  # пока функция не написана, «даром» пройденные тесты не засчитываются\n",
    "        for r in results:\n",
    "            if r[\"status\"] == \"passed\":\n",
    "                r.update(status=\"not_written\", message=\"не засчитан, пока функция не написана\")\n",
    "    for r in results:\n",
    "        if len(r[\"message\"]) > 300:\n",
    "            r[\"message\"] = r[\"message\"][:300] + \"…\"\n",
    "\n",
    "    if verbose:\n",
    "        if not_written:\n",
    "            print(\"Функция ещё не написана — замените raise NotImplementedError своим кодом.\\n\")\n",
    "        for r in results:\n",
    "            mark = {\"passed\": \"✓\", \"not_run\": \"–\"}.get(r[\"status\"], \"✗\")\n",
    "            print(f\"{mark} {r['title']}\")\n",
    "            if r[\"message\"]:\n",
    "                print(f\"    {r['message']}\")\n",
    "        passed = sum(r[\"status\"] == \"passed\" for r in results)\n",
    "        print(\"─\" * 40)\n",
    "        print(f\"Прошло {passed} из {len(results)}\" + (\" — всё верно!\" if passed == len(results) else \"\"))\n",
    "        if hung:\n",
    "            print(\"Выполнение остановлено: один из тестов завис.\")\n",
    "            sys.stdout.flush()\n",
    "            os._exit(1)\n",
    "    return results\n",
    "\n",
    "\n",
    "def _check(*tests, names=()):\n",
    "    \"\"\"Запускает проверки упражнения и печатает ✓ / ✗ и итог — как на сайте.\"\"\"\n",
    "    for name in names:\n",
    "        if \".\" in name:  # «Класс.метод» из заготовки: класс должен быть объявлен\n",
    "            if not isinstance(globals().get(name.split(\".\")[0]), type):\n",
    "                print(f\"✗ класс {name.split('.')[0]} не объявлен — выполните ячейку упражнения\")\n",
    "                return\n",
    "            continue\n",
    "        if name not in globals():\n",
    "            print(f\"✗ переменная {name} не создана — выполните ячейку упражнения\")\n",
    "            return\n",
    "        if globals()[name] is Ellipsis:\n",
    "            print(f\"✗ {name} пока равна ... — замените многоточие своим кодом\")\n",
    "            return\n",
    "    globals()[\"_TESTS\"] = list(tests)\n",
    "    _run_tests()"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "setup"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Модель кассы есть: товары, строки и корзины. Теперь — **правила**: какую скидку дать, как принять оплату,\n",
    "как напечатать чек и посчитать выручку за день. Каждое правило — отдельный класс или иерархия классов, а\n",
    "касса `Register` собирает их вместе композицией.\n",
    "\n",
    "| Часть кассы | Что это | Инструмент |\n",
    "| --- | --- | --- |\n",
    "| скидки | варианты одного поведения | абстрактный класс + подклассы, полиморфизм |\n",
    "| оплата | варианты одного поведения, свои ошибки | абстрактный класс, `PaymentError` |\n",
    "| чек | данные и текст для печати | dataclass, свойство, `__str__` |\n",
    "| касса | всё вместе | композиция, `max(..., key=...)`, исключения |\n",
    "\n",
    "## Что у нас уже есть\n",
    "\n",
    "Упрощённая модель из первой части: корзина сразу получает строки (каталог здесь не нужен), но умеет то же,\n",
    "что ваша, — `subtotal`, `len` и цикл по строкам. Готов и «договор» для оплаты — абстрактный `PaymentMethod`\n",
    "с наличными `Cash`, как в мини-проекте модуля 6:"
   ],
   "id": "text-10"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Товары, строки, корзины, наличные\n",
    "from abc import ABC, abstractmethod\n",
    "from dataclasses import dataclass, field\n",
    "\n",
    "\n",
    "class ShopError(Exception):\n",
    "    pass\n",
    "\n",
    "\n",
    "@dataclass(frozen=True)\n",
    "class Product:\n",
    "    name: str\n",
    "    price: int\n",
    "    category: str\n",
    "\n",
    "\n",
    "@dataclass\n",
    "class Line:\n",
    "    product: Product\n",
    "    qty: int\n",
    "\n",
    "    @property\n",
    "    def cost(self):\n",
    "        return self.product.price * self.qty\n",
    "\n",
    "\n",
    "@dataclass\n",
    "class Cart:\n",
    "    lines: list = field(default_factory=list)\n",
    "\n",
    "    def __len__(self):\n",
    "        return len(self.lines)\n",
    "\n",
    "    def __iter__(self):\n",
    "        return iter(self.lines)\n",
    "\n",
    "    @property\n",
    "    def subtotal(self):\n",
    "        return sum(line.cost for line in self.lines)\n",
    "\n",
    "\n",
    "class PaymentError(ShopError):\n",
    "    pass\n",
    "\n",
    "\n",
    "class PaymentMethod(ABC):\n",
    "    @abstractmethod\n",
    "    def pay(self, amount):\n",
    "        ...\n",
    "\n",
    "\n",
    "class Cash(PaymentMethod):\n",
    "    def __init__(self, given):\n",
    "        self.given = given\n",
    "\n",
    "    def pay(self, amount):\n",
    "        if self.given < amount:\n",
    "            raise PaymentError(\"недостаточно наличных\")\n",
    "        return f\"наличные {self.given} ₽, сдача {self.given - amount} ₽\"\n",
    "\n",
    "\n",
    "latte = Product(\"Латте\", 240, \"кофе\")\n",
    "croissant = Product(\"Круассан\", 140, \"выпечка\")\n",
    "muffin = Product(\"Маффин\", 130, \"выпечка\")\n",
    "tea = Product(\"Чай\", 120, \"чай\")\n",
    "raf = Product(\"Раф\", 260, \"кофе\")\n",
    "\n",
    "sample = Cart([Line(latte, 3), Line(croissant, 2)])\n",
    "print(sample.subtotal, len(sample), [line.cost for line in sample])\n",
    "print(Cash(1000).pay(900))"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "model"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 1. Скидки"
   ],
   "id": "text-29"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Абстрактная скидка\n",
    "\n",
    "Объявите абстрактный класс `Discount`: `__init__(self, title)` записывает название акции в `title`,\n",
    "`str(discount)` возвращает его, абстрактный метод `amount(cart)` — сколько рублей скидка даёт корзине.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`class Discount(ABC):` и `@abstractmethod` над `amount`.\n",
    "\n",
    "</details>"
   ],
   "id": "discount-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Discount:\n",
    "    def __init__(self, title):\n",
    "        ...\n",
    "\n",
    "    def __str__(self):\n",
    "        ...\n",
    "\n",
    "    def amount(self, cart):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "discount"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_abstract():\n",
    "    \"Discount — абстрактный\"\n",
    "    assert issubclass(Discount, ABC), \"Discount должен наследовать от ABC\"\n",
    "    assert \"amount\" in getattr(Discount, \"__abstractmethods__\", set()), \"amount должен быть абстрактным — @abstractmethod\"\n",
    "\n",
    "\n",
    "def test_title():\n",
    "    \"title и str\"\n",
    "    class Zero(Discount):\n",
    "        def amount(self, cart):\n",
    "            return 0\n",
    "    zero = Zero(\"без скидки\")\n",
    "    assert getattr(zero, \"title\", None) == \"без скидки\", \"название хранится в title\"\n",
    "    assert str(zero) == \"без скидки\", f\"str = {str(zero)!r}\"\n",
    "\n",
    "_check(test_abstract, test_title, names=[\"Discount.__init__\",\"Discount.__str__\",\"Discount.amount\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "discount-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Discount(ABC):\n",
    "    def __init__(self, title):\n",
    "        self.title = title\n",
    "\n",
    "    def __str__(self):\n",
    "        return self.title\n",
    "\n",
    "    @abstractmethod\n",
    "    def amount(self, cart):\n",
    "        ...\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "discount-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Процент и категория\n",
    "\n",
    "Объявите два подкласса `Discount`. `PercentDiscount(10)` — название `\"скидка 10 %\"`, скидка — 10 % от\n",
    "`subtotal` корзины. `CategoryDiscount(\"выпечка\", 20)` — название `\"выпечка -20 %\"`, скидка — 20 % от стоимости\n",
    "только тех строк корзины, у товара которых такая категория. Обе скидки округляются до целых.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Название передайте в `super().__init__(…)`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Строки корзины даёт цикл `for line in cart`; сложите `cost` тех, у товара которых нужная категория.\n",
    "\n",
    "</details>"
   ],
   "id": "discounts-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class PercentDiscount(Discount):\n",
    "    def __init__(self, percent):\n",
    "        ...\n",
    "\n",
    "    def amount(self, cart):\n",
    "        ...\n",
    "\n",
    "\n",
    "class CategoryDiscount(Discount):\n",
    "    def __init__(self, category, percent):\n",
    "        ...\n",
    "\n",
    "    def amount(self, cart):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "discounts"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_percent():\n",
    "    \"PercentDiscount\"\n",
    "    d = PercentDiscount(10)\n",
    "    assert hasattr(d, \"title\"), \"у скидки нет title — вызовите super().__init__(название)\"\n",
    "    assert str(d) == \"скидка 10 %\", f\"название {str(d)!r}\"\n",
    "    got = d.amount(Cart([Line(latte, 3), Line(croissant, 2)]))\n",
    "    assert isinstance(got, int), f\"скидка {got!r} — дробное число; округлите до целых: round(...)\"\n",
    "    assert got == 100, \"10 % от корзины на 1000 ₽ — 100\"\n",
    "\n",
    "\n",
    "def test_category():\n",
    "    \"CategoryDiscount — только своя категория\"\n",
    "    d = CategoryDiscount(\"выпечка\", 20)\n",
    "    assert hasattr(d, \"title\"), \"у скидки нет title — вызовите super().__init__(название)\"\n",
    "    assert str(d) == \"выпечка -20 %\", f\"название {str(d)!r}, а нужно \\\"выпечка -20 %\\\"\"\n",
    "    cart = Cart([Line(latte, 3), Line(croissant, 2)])\n",
    "    got = d.amount(cart)\n",
    "    assert isinstance(got, int), f\"скидка {got!r} — дробное число; округлите до целых: round(...)\"\n",
    "    assert got != 200, \"скидка посчитана от всей корзины, а нужно только от строк категории «выпечка»\"\n",
    "    assert got == 56, f\"20 % от выпечки (280 ₽) = {got!r}, а нужно 56\"\n",
    "    assert d.amount(Cart([Line(tea, 1)])) == 0, \"без выпечки скидка 0\"\n",
    "\n",
    "_check(test_percent, test_category, names=[\"PercentDiscount.__init__\",\"PercentDiscount.amount\",\"CategoryDiscount.__init__\",\"CategoryDiscount.amount\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "discounts-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class PercentDiscount(Discount):\n",
    "    def __init__(self, percent):\n",
    "        super().__init__(f\"скидка {percent} %\")\n",
    "        self.percent = percent\n",
    "\n",
    "    def amount(self, cart):\n",
    "        return round(cart.subtotal * self.percent / 100)\n",
    "\n",
    "\n",
    "class CategoryDiscount(Discount):\n",
    "    def __init__(self, category, percent):\n",
    "        super().__init__(f\"{category} -{percent} %\")\n",
    "        self.category = category\n",
    "        self.percent = percent\n",
    "\n",
    "    def amount(self, cart):\n",
    "        base = sum(line.cost for line in cart if line.product.category == self.category)\n",
    "        return round(base * self.percent / 100)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "discounts-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 2. Оплата"
   ],
   "id": "text-47"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Карта\n",
    "\n",
    "Наличные `Cash` уже есть. Объявите второй способ оплаты — подкласс `Card` класса `PaymentMethod`:\n",
    "`Card(2000)` хранит остаток на счёте в `balance`, `pay(amount)` списывает сумму и возвращает\n",
    "`\"карта: 664 ₽\"`, а если денег меньше — выбрасывает `PaymentError(\"недостаточно средств\")`, и баланс не\n",
    "меняется.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Устроен как `Cash` из демонстрации: проверка с `raise`, потом изменение баланса и строка для чека.\n",
    "\n",
    "</details>"
   ],
   "id": "payments-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Card(PaymentMethod):\n",
    "    def __init__(self, balance):\n",
    "        ...\n",
    "\n",
    "    def pay(self, amount):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "payments"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def _payment_error(action):\n",
    "    try:\n",
    "        action()\n",
    "    except PaymentError as e:\n",
    "        return str(e)\n",
    "    except Exception as e:\n",
    "        assert False, f\"выброшен {type(e).__name__}, а нужен PaymentError\"\n",
    "    return None\n",
    "\n",
    "\n",
    "def test_card():\n",
    "    \"оплата картой\"\n",
    "    assert issubclass(Card, PaymentMethod), \"Card должен наследовать от PaymentMethod\"\n",
    "    card = Card(2000)\n",
    "    assert card.pay(664) == \"карта: 664 ₽\", \"Card(2000).pay(664) должен вернуть \\\"карта: 664 ₽\\\"\"\n",
    "    assert card.balance == 1336, f\"после оплаты баланс {card.balance}, а нужно 1336\"\n",
    "\n",
    "\n",
    "def test_not_enough():\n",
    "    \"не хватает — PaymentError, баланс прежний\"\n",
    "    card = Card(1000)\n",
    "    assert _payment_error(lambda: card.pay(5000)) == \"недостаточно средств\", \"оплата больше баланса должна выбросить PaymentError(\\\"недостаточно средств\\\")\"\n",
    "    assert card.balance == 1000, \"после отказа баланс не должен меняться\"\n",
    "\n",
    "_check(test_card, test_not_enough, names=[\"Card.__init__\",\"Card.pay\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "payments-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Card(PaymentMethod):\n",
    "    def __init__(self, balance):\n",
    "        self.balance = balance\n",
    "\n",
    "    def pay(self, amount):\n",
    "        if amount > self.balance:\n",
    "            raise PaymentError(\"недостаточно средств\")\n",
    "        self.balance -= amount\n",
    "        return f\"карта: {amount} ₽\"\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "payments-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Чек"
   ],
   "id": "text-58"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Receipt\n",
    "\n",
    "В заготовке — dataclass `Receipt` с полями `number`, `subtotal`, `discount` (название акции), `saved` (скидка\n",
    "в рублях), `payment` (строка оплаты) и свойством `total`. Допишите `__str__`: текст чека — пять строк:\n",
    "\n",
    "```text\n",
    "Чек №1\n",
    "Сумма: 1000 ₽\n",
    "Скидка «скидка 10 %»: -100 ₽\n",
    "К оплате: 900 ₽\n",
    "Оплата: наличные 1000 ₽, сдача 100 ₽\n",
    "```\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Соберите список из пяти строк и соедините их: `\"\\n\".join(lines)`.\n",
    "\n",
    "</details>"
   ],
   "id": "receipt-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "@dataclass\n",
    "class Receipt:\n",
    "    number: int\n",
    "    subtotal: int\n",
    "    discount: str\n",
    "    saved: int\n",
    "    payment: str\n",
    "\n",
    "    @property\n",
    "    def total(self):\n",
    "        return self.subtotal - self.saved\n",
    "\n",
    "    def __str__(self):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "receipt"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "from dataclasses import fields, is_dataclass\n",
    "\n",
    "\n",
    "def _receipt():\n",
    "    return Receipt(1, 1000, \"скидка 10 %\", 100, \"наличные 1000 ₽, сдача 100 ₽\")\n",
    "\n",
    "\n",
    "def test_fields():\n",
    "    \"dataclass и total\"\n",
    "    assert is_dataclass(Receipt), \"Receipt — не dataclass\"\n",
    "    assert [f.name for f in fields(Receipt)] == [\"number\", \"subtotal\", \"discount\", \"saved\", \"payment\"], f\"поля: {[f.name for f in fields(Receipt)]}\"\n",
    "    assert isinstance(Receipt.__dict__.get(\"total\"), property), \"total должен быть свойством\"\n",
    "    assert _receipt().total == 900, f\"total = {_receipt().total!r}, а нужно 900\"\n",
    "\n",
    "\n",
    "def test_str():\n",
    "    \"текст чека\"\n",
    "    assert is_dataclass(Receipt), \"сначала объявите Receipt dataclass'ом\"\n",
    "    expected = \"Чек №1\\nСумма: 1000 ₽\\nСкидка «скидка 10 %»: -100 ₽\\nК оплате: 900 ₽\\nОплата: наличные 1000 ₽, сдача 100 ₽\"\n",
    "    got = str(_receipt())\n",
    "    assert got.split(\"\\n\")[0] == \"Чек №1\", f\"первая строка чека: {got.split(chr(10))[0]!r}\"\n",
    "    assert got == expected, f\"текст чека:\\n{got}\"\n",
    "\n",
    "_check(test_fields, test_str, names=[\"Receipt.__str__\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "receipt-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "@dataclass\n",
    "class Receipt:\n",
    "    number: int\n",
    "    subtotal: int\n",
    "    discount: str\n",
    "    saved: int\n",
    "    payment: str\n",
    "\n",
    "    @property\n",
    "    def total(self):\n",
    "        return self.subtotal - self.saved\n",
    "\n",
    "    def __str__(self):\n",
    "        lines = [\n",
    "            f\"Чек №{self.number}\",\n",
    "            f\"Сумма: {self.subtotal} ₽\",\n",
    "            f\"Скидка «{self.discount}»: -{self.saved} ₽\",\n",
    "            f\"К оплате: {self.total} ₽\",\n",
    "            f\"Оплата: {self.payment}\",\n",
    "        ]\n",
    "        return \"\\n\".join(lines)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "receipt-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 4. Касса"
   ],
   "id": "text-75"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 5. Register\n",
    "\n",
    "Объявите класс `Register`: `Register([скидка, скидка, …])` хранит доступные скидки и список выданных чеков\n",
    "`receipts` (у новой кассы — пустой).\n",
    "\n",
    "- `best_discount(cart)` — скидка, которая даёт корзине больше всего рублей;\n",
    "- `checkout(cart, method)` — провести покупку: пустая корзина (`subtotal` равен 0) — `ShopError(\"пустая\n",
    "  корзина\")`; иначе выбрать лучшую скидку, оплатить `method` сумму к оплате (ошибка оплаты уходит дальше,\n",
    "  чек не создаётся), создать `Receipt` со следующим номером (1, 2, 3… по выданным чекам), сохранить его в\n",
    "  `receipts` и вернуть.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Номер чека — `len(self.receipts) + 1`, и его берут только после успешной оплаты.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "Порядок в `checkout`: проверка корзины → скидка → `payment = method.pay(total)` → чек.\n",
    "\n",
    "</details>"
   ],
   "id": "register-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Register:\n",
    "    def __init__(self, discounts):\n",
    "        ...\n",
    "\n",
    "    def best_discount(self, cart):\n",
    "        ...\n",
    "\n",
    "    def checkout(self, cart, method):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "register"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 5 — выполните после своего решения\n",
    "def _register():\n",
    "    return Register([PercentDiscount(10), CategoryDiscount(\"выпечка\", 20)])\n",
    "\n",
    "\n",
    "def test_best():\n",
    "    \"best_discount — наибольшая скидка\"\n",
    "    reg = _register()\n",
    "    assert getattr(reg, \"receipts\", None) == [], \"у новой кассы receipts — пустой список\"\n",
    "    got = reg.best_discount(Cart([Line(croissant, 3), Line(muffin, 2), Line(tea, 1)]))\n",
    "    assert isinstance(got, Discount), f\"best_discount вернул {got!r}, а нужна сама скидка\"\n",
    "    assert str(got) == \"выпечка -20 %\", f\"для корзины с выпечкой лучшая скидка — «выпечка -20 %» (136 ₽ против 80 ₽), а выбрана {got}\"\n",
    "\n",
    "\n",
    "def test_checkout():\n",
    "    \"checkout выдаёт чек и сохраняет его\"\n",
    "    reg = _register()\n",
    "    receipt = reg.checkout(Cart([Line(latte, 3), Line(croissant, 2)]), Cash(1000))\n",
    "    assert isinstance(receipt, Receipt), f\"checkout вернул {type(receipt).__name__}, а нужен Receipt\"\n",
    "    assert isinstance(receipt.saved, int), f\"скидка в чеке {receipt.saved!r} — дробное число: округлите скидку в amount через round\"\n",
    "    assert (receipt.number, receipt.subtotal, receipt.discount, receipt.saved, receipt.total) == (1, 1000, \"скидка 10 %\", 100, 900), f\"чек: {receipt!r}\"\n",
    "    assert receipt.payment == \"наличные 1000 ₽, сдача 100 ₽\", f\"строка оплаты: {receipt.payment!r} — платить нужно сумму после скидки\"\n",
    "    second = reg.checkout(Cart([Line(tea, 2)]), Card(1000))\n",
    "    assert second.number == 2 and reg.receipts == [receipt, second], \"второй чек — №2, оба чека в receipts\"\n",
    "\n",
    "\n",
    "def test_errors():\n",
    "    \"пустая корзина и неудачная оплата — без чека\"\n",
    "    reg = _register()\n",
    "    try:\n",
    "        reg.checkout(Cart(), Cash(100))\n",
    "    except ShopError as e:\n",
    "        assert str(e) == \"пустая корзина\", f\"сообщение {str(e)!r}\"\n",
    "    else:\n",
    "        assert False, \"пустую корзину касса провела — нужен ShopError(\\\"пустая корзина\\\")\"\n",
    "    try:\n",
    "        reg.checkout(Cart([Line(raf, 1)]), Cash(200))\n",
    "    except PaymentError:\n",
    "        pass\n",
    "    else:\n",
    "        assert False, \"оплата 200 ₽ за 234 ₽ прошла — ошибка оплаты должна уйти из checkout\"\n",
    "    assert reg.receipts == [], \"после неудачных покупок чеков быть не должно\"\n",
    "    ok = reg.checkout(Cart([Line(tea, 1)]), Cash(200))\n",
    "    assert ok.number == 1, f\"первый успешный чек — №{ok.number}, а должен быть №1: номер берите после оплаты\"\n",
    "\n",
    "_check(test_best, test_checkout, test_errors, names=[\"Register.__init__\",\"Register.best_discount\",\"Register.checkout\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "register-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Register:\n",
    "    def __init__(self, discounts):\n",
    "        self.discounts = list(discounts)\n",
    "        self.receipts = []\n",
    "\n",
    "    def best_discount(self, cart):\n",
    "        return max(self.discounts, key=lambda d: d.amount(cart))\n",
    "\n",
    "    def checkout(self, cart, method):\n",
    "        if cart.subtotal == 0:\n",
    "            raise ShopError(\"пустая корзина\")\n",
    "        discount = self.best_discount(cart)\n",
    "        saved = discount.amount(cart)\n",
    "        payment = method.pay(cart.subtotal - saved)\n",
    "        receipt = Receipt(len(self.receipts) + 1, cart.subtotal, str(discount), saved, payment)\n",
    "        self.receipts.append(receipt)\n",
    "        return receipt\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "register-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 5. День на кассе"
   ],
   "id": "text-91"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 6. Отчёт за день\n",
    "\n",
    "Касса работает со скидками `PercentDiscount(10)` и `CategoryDiscount(\"выпечка\", 20)`. Проведите покупки\n",
    "из `queue` (пары «корзина, способ оплаты») через одну кассу `register`. Ошибки кассы перехватывайте и\n",
    "считайте: `failed` — сколько покупок не прошло. Сохраните в `revenue` выручку — сумму `total` всех чеков,\n",
    "а в `biggest` — номер чека с наибольшей суммой к оплате.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`try: register.checkout(cart, method)` / `except ShopError: failed += 1`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`max(register.receipts, key=lambda r: r.total).number`.\n",
    "\n",
    "</details>"
   ],
   "id": "day-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "card = Card(2000)\n",
    "queue = [\n",
    "    (Cart([Line(latte, 3), Line(croissant, 2)]), Cash(1000)),\n",
    "    (Cart([Line(croissant, 3), Line(muffin, 2), Line(tea, 1)]), card),\n",
    "    (Cart([Line(raf, 1)]), Cash(200)),\n",
    "    (Cart(), Cash(500)),\n",
    "    (Cart([Line(tea, 2), Line(muffin, 1)]), card),\n",
    "]\n",
    "register = ...\n",
    "failed = ...\n",
    "revenue = ...\n",
    "biggest = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "day"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 6 — выполните после своего решения\n",
    "def test_register():\n",
    "    \"три чека\"\n",
    "    assert isinstance(register, Register), f\"register — это {type(register).__name__}, а нужна касса Register([...])\"\n",
    "    assert len(register.receipts) == 3, f\"у кассы {len(register.receipts)} чеков, а прошли 3 покупки из 5\"\n",
    "    assert [r.total for r in register.receipts] == [900, 664, 333], f\"суммы чеков: {[r.total for r in register.receipts]}\"\n",
    "\n",
    "\n",
    "def test_report():\n",
    "    \"failed, revenue, biggest\"\n",
    "    assert failed == 2, f\"failed = {failed!r}, а не прошли 2 покупки: раф (не хватило наличных) и пустая корзина\"\n",
    "    assert revenue == 1897, f\"revenue = {revenue!r}, а выручка — 1897 ₽\"\n",
    "    assert biggest == 1, f\"biggest = {biggest!r}, а больше всего — в чеке №1 (900 ₽)\"\n",
    "\n",
    "_check(test_register, test_report, names=[\"register\",\"failed\",\"revenue\",\"biggest\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "day-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "card = Card(2000)\n",
    "queue = [\n",
    "    (Cart([Line(latte, 3), Line(croissant, 2)]), Cash(1000)),\n",
    "    (Cart([Line(croissant, 3), Line(muffin, 2), Line(tea, 1)]), card),\n",
    "    (Cart([Line(raf, 1)]), Cash(200)),\n",
    "    (Cart(), Cash(500)),\n",
    "    (Cart([Line(tea, 2), Line(muffin, 1)]), card),\n",
    "]\n",
    "register = Register([PercentDiscount(10), CategoryDiscount(\"выпечка\", 20)])\n",
    "failed = 0\n",
    "for cart, method in queue:\n",
    "    try:\n",
    "        register.checkout(cart, method)\n",
    "    except ShopError:\n",
    "        failed += 1\n",
    "revenue = sum(r.total for r in register.receipts)\n",
    "biggest = max(register.receipts, key=lambda r: r.total).number\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "day-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итог"
   ],
   "id": "text-103"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Чеки за день\n",
    "for receipt in register.receipts:\n",
    "    print(receipt)\n",
    "    print()\n",
    "print(f\"не прошло покупок: {failed}; выручка: {revenue} ₽; самый большой чек — №{biggest}\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "summary"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "Из пяти покупок прошли три: в первой лучшей оказалась скидка 10 % (100 ₽ против 56 ₽ на выпечку), во второй —\n",
    "скидка на выпечку (136 ₽). Покупка рафа не прошла — 200 ₽ наличными меньше 234 ₽ к оплате, а пустую корзину\n",
    "касса не провела вовсе. Выручка — 1897 ₽."
   ],
   "id": "component-108"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Что получилось\n",
    "\n",
    "Касса «Кофейной лавки» — это дюжина небольших классов, и каждый отвечает за одно: `Product` и `Line` хранят\n",
    "данные, `Cart` — строки, скидки и способы оплаты — варианты поведения за общим абстрактным интерфейсом,\n",
    "`Receipt` — данные чека и его текст, `Register` связывает всё вместе. Новая акция или новый способ оплаты — это\n",
    "новый подкласс; касса и отчёт не меняются. Это и есть цель объектно-ориентированного проектирования.\n",
    "\n",
    "## Что дальше\n",
    "\n",
    "Курс закончен, но у ООП в Python есть куда расти. Темы в порядке пользы:\n",
    "\n",
    "- **Аннотации типов и `typing.Protocol`.** Аннотации можно писать не только в dataclass, но и в любых\n",
    "  методах, а программы-проверщики (mypy, pyright) найдут ошибки до запуска. `Protocol` описывает «утиный»\n",
    "  интерфейс без наследования — как способы доставки из модуля 5.\n",
    "- **Тесты.** Проверки в упражнениях курса — это тесты. В настоящих проектах их пишут с библиотекой pytest:\n",
    "  функции `test_…` с `assert`, как здесь.\n",
    "- **Паттерны проектирования.** Шаблонный метод, стратегия (скидки и способы оплаты — это она), фабрика\n",
    "  (альтернативные конструкторы) — у типовых решений есть имена; книги о паттернах помогают узнавать их в\n",
    "  чужом коде.\n",
    "- **`__slots__`, `__getattr__`, дескрипторы.** Как `property` устроено изнутри и как сэкономить память на\n",
    "  миллионе экземпляров. Это продвинутые темы, к ним возвращаются по необходимости.\n",
    "- **Классы библиотек.** Попробуйте прочитать устройство знакомых объектов: `pathlib.Path` перегружает\n",
    "  оператор `/` (`Path(\"data\") / \"orders.csv\"`), а `DataFrame` в pandas — контейнер с `__getitem__`, `__len__`\n",
    "  и `__iter__`. Теперь вы понимаете, почему `df[\"price\"]`, `len(df)` и `for column in df` работают.\n",
    "\n",
    "Задачи для практики по всем темам курса появятся в разделе «Задачи», а справочник по ООП — во вкладке\n",
    "«Справочник»."
   ],
   "id": "text-113"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
