{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Мини-проект: оплата\n",
    "\n",
    "Абстрактный PaymentMethod и три способа оплаты — наличные, карта и баллы. Заказ содержит способ оплаты (композиция), создаётся из строки методом класса, а касса проводит день оплат.\n",
    "\n",
    "Курс ООП в Python · урок `oop-p6-payments` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/oop/p6-payments).\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",
    "он **содержит** способ оплаты и просит его заплатить нужную сумму. В проекте собраны все три идеи модуля:\n",
    "абстрактный класс, альтернативный конструктор и композиция.\n",
    "\n",
    "## Шаг 1. Договор для способов оплаты"
   ],
   "id": "text-10"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. Абстрактный PaymentMethod\n",
    "\n",
    "Объявите абстрактный класс `PaymentMethod`. `__init__(self, title)` записывает название способа в `title`,\n",
    "`str()` возвращает это название. Абстрактный метод `pay(amount)` подклассы реализуют так: провести оплату\n",
    "и вернуть строку для чека, а если денег не хватает — выбросить `ValueError` и ничего не менять.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`class PaymentMethod(ABC):`, `@abstractmethod` над `pay`, тело — `...`.\n",
    "\n",
    "</details>"
   ],
   "id": "payment-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "from abc import ABC, abstractmethod\n",
    "\n",
    "\n",
    "class PaymentMethod:\n",
    "    def __init__(self, title):\n",
    "        ...\n",
    "\n",
    "    def __str__(self):\n",
    "        ...\n",
    "\n",
    "    def pay(self, amount):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "payment"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_abstract():\n",
    "    \"PaymentMethod — абстрактный, pay обязателен\"\n",
    "    assert issubclass(PaymentMethod, ABC), \"PaymentMethod должен наследовать от ABC\"\n",
    "    assert \"pay\" in getattr(PaymentMethod, \"__abstractmethods__\", set()), \"pay должен быть абстрактным — @abstractmethod\"\n",
    "    try:\n",
    "        PaymentMethod(\"что-то\")\n",
    "    except TypeError:\n",
    "        pass\n",
    "    else:\n",
    "        assert False, \"экземпляр PaymentMethod создаётся, а у абстрактного класса не должен\"\n",
    "\n",
    "\n",
    "def test_title():\n",
    "    \"title и str у подкласса\"\n",
    "    class Free(PaymentMethod):\n",
    "        def pay(self, amount):\n",
    "            return \"бесплатно\"\n",
    "    free = Free(\"подарок\")\n",
    "    assert getattr(free, \"title\", None) == \"подарок\", \"название хранится в атрибуте title\"\n",
    "    assert str(free) == \"подарок\", f\"str() способа оплаты = {str(free)!r}, а нужно название\"\n",
    "\n",
    "_check(test_abstract, test_title, names=[\"PaymentMethod.__init__\",\"PaymentMethod.__str__\",\"PaymentMethod.pay\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "payment-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "from abc import ABC, abstractmethod\n",
    "\n",
    "\n",
    "class PaymentMethod(ABC):\n",
    "    def __init__(self, title):\n",
    "        self.title = title\n",
    "\n",
    "    def __str__(self):\n",
    "        return self.title\n",
    "\n",
    "    @abstractmethod\n",
    "    def pay(self, amount):\n",
    "        ...\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "payment-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 2. Три способа оплаты"
   ],
   "id": "text-25"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Наличные\n",
    "\n",
    "Объявите подкласс `Cash`: `Cash(1000)` — покупатель дал 1000 ₽ (атрибут `given`), название — `\"наличные\"`.\n",
    "`pay(640)` возвращает `\"наличные 1000 ₽, сдача 360 ₽\"`, а если дали меньше суммы —\n",
    "`ValueError(\"недостаточно наличных\")`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`super().__init__(\"наличные\")`, затем `self.given = given`.\n",
    "\n",
    "</details>"
   ],
   "id": "cash-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Cash(PaymentMethod):\n",
    "    def __init__(self, given):\n",
    "        ...\n",
    "\n",
    "    def pay(self, amount):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "cash"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "def test_cash():\n",
    "    \"сдача и строка чека\"\n",
    "    cash = Cash(1000)\n",
    "    assert hasattr(cash, \"title\"), \"у способа оплаты нет title — вызовите в __init__ super().__init__(название)\"\n",
    "    assert str(cash) == \"наличные\", f\"название наличных — {str(cash)!r}, а нужно \\\"наличные\\\": super().__init__(\\\"наличные\\\")\"\n",
    "    assert getattr(cash, \"given\", None) == 1000, \"сумма от покупателя хранится в атрибуте given\"\n",
    "    got = cash.pay(640)\n",
    "    assert got == \"наличные 1000 ₽, сдача 360 ₽\", f\"pay(640) вернул {got!r}\"\n",
    "    assert Cash(640).pay(640) == \"наличные 640 ₽, сдача 0 ₽\", \"без сдачи — «сдача 0 ₽»\"\n",
    "\n",
    "\n",
    "def test_not_enough():\n",
    "    \"дали меньше — ValueError\"\n",
    "    try:\n",
    "        Cash(500).pay(640)\n",
    "    except ValueError as e:\n",
    "        assert str(e) == \"недостаточно наличных\", f\"сообщение {str(e)!r}, а нужно \\\"недостаточно наличных\\\"\"\n",
    "        return\n",
    "    assert False, \"Cash(500).pay(640) не выбросил ValueError\"\n",
    "\n",
    "_check(test_cash, test_not_enough, names=[\"Cash.__init__\",\"Cash.pay\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "cash-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Cash(PaymentMethod):\n",
    "    def __init__(self, given):\n",
    "        super().__init__(\"наличные\")\n",
    "        self.given = given\n",
    "\n",
    "    def pay(self, amount):\n",
    "        if self.given < amount:\n",
    "            raise ValueError(\"недостаточно наличных\")\n",
    "        return f\"наличные {self.given} ₽, сдача {self.given - amount} ₽\"\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "cash-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Банковская карта\n",
    "\n",
    "Объявите подкласс `Card`: `Card(\"2200 1234 5678 9012\", 2000)` — номер карты (`number`) и остаток на счёте\n",
    "(`balance`), название — `\"карта *9012\"` (последние четыре цифры номера). `pay(amount)` списывает сумму с\n",
    "`balance` и возвращает `\"карта *9012: списано 1250 ₽\"`; если денег на счёте меньше суммы —\n",
    "`ValueError(\"недостаточно средств на карте\")`, остаток не меняется.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Последние четыре символа строки — `number[-4:]`.\n",
    "\n",
    "</details>"
   ],
   "id": "card-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Card(PaymentMethod):\n",
    "    def __init__(self, number, balance):\n",
    "        ...\n",
    "\n",
    "    def pay(self, amount):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "card"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def _card():\n",
    "    return Card(\"2200 1234 5678 9012\", 2000)\n",
    "\n",
    "\n",
    "def test_title():\n",
    "    \"название — последние 4 цифры\"\n",
    "    assert hasattr(_card(), \"title\"), \"у способа оплаты нет title — вызовите в __init__ super().__init__(название)\"\n",
    "    assert str(_card()) == \"карта *9012\", f\"название карты — {str(_card())!r}, а нужно \\\"карта *9012\\\"\"\n",
    "\n",
    "\n",
    "def test_pay():\n",
    "    \"списание и строка чека\"\n",
    "    card = _card()\n",
    "    assert hasattr(card, \"title\"), \"у способа оплаты нет title — вызовите в __init__ super().__init__(название)\"\n",
    "    got = card.pay(1250)\n",
    "    assert got == \"карта *9012: списано 1250 ₽\", f\"pay(1250) вернул {got!r}\"\n",
    "    assert card.balance == 750, f\"после оплаты 1250 ₽ остаток {card.balance}, а нужно 750\"\n",
    "\n",
    "\n",
    "def test_not_enough():\n",
    "    \"не хватает — ValueError, остаток прежний\"\n",
    "    card = _card()\n",
    "    try:\n",
    "        card.pay(2100)\n",
    "    except ValueError as e:\n",
    "        assert str(e) == \"недостаточно средств на карте\", f\"сообщение {str(e)!r}\"\n",
    "    else:\n",
    "        assert False, \"pay(2100) при остатке 2000 не выбросил ValueError\"\n",
    "    assert card.balance == 2000, f\"после отказа остаток {card.balance} — он не должен меняться\"\n",
    "\n",
    "_check(test_title, test_pay, test_not_enough, names=[\"Card.__init__\",\"Card.pay\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "card-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Card(PaymentMethod):\n",
    "    def __init__(self, number, balance):\n",
    "        super().__init__(f\"карта *{number[-4:]}\")\n",
    "        self.number = number\n",
    "        self.balance = balance\n",
    "\n",
    "    def pay(self, amount):\n",
    "        if amount > self.balance:\n",
    "            raise ValueError(\"недостаточно средств на карте\")\n",
    "        self.balance -= amount\n",
    "        return f\"{self.title}: списано {amount} ₽\"\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "card-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Баллы лояльности\n",
    "\n",
    "Объявите подкласс `LoyaltyPoints`: `LoyaltyPoints(500)` — баллов на карте (`points`), 1 балл = 1 ₽, название —\n",
    "`\"баллы\"`. `pay(380)` списывает баллы и возвращает `\"баллы: списано 380, осталось 120\"`; если баллов меньше\n",
    "суммы — `ValueError(\"недостаточно баллов\")`, баллы не меняются.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Устроено как `Card`: проверка, списание, строка для чека.\n",
    "\n",
    "</details>"
   ],
   "id": "points-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class LoyaltyPoints(PaymentMethod):\n",
    "    def __init__(self, points):\n",
    "        ...\n",
    "\n",
    "    def pay(self, amount):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "points"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_pay():\n",
    "    \"списание баллов\"\n",
    "    points = LoyaltyPoints(500)\n",
    "    assert hasattr(points, \"title\"), \"у способа оплаты нет title — вызовите в __init__ super().__init__(название)\"\n",
    "    assert str(points) == \"баллы\", f\"название — {str(points)!r}, а нужно \\\"баллы\\\"\"\n",
    "    got = points.pay(380)\n",
    "    assert got == \"баллы: списано 380, осталось 120\", f\"pay(380) вернул {got!r}\"\n",
    "    assert points.points == 120, f\"после оплаты осталось {points.points} баллов, а нужно 120\"\n",
    "\n",
    "\n",
    "def test_not_enough():\n",
    "    \"не хватает — ValueError\"\n",
    "    points = LoyaltyPoints(100)\n",
    "    try:\n",
    "        points.pay(380)\n",
    "    except ValueError as e:\n",
    "        assert str(e) == \"недостаточно баллов\", f\"сообщение {str(e)!r}\"\n",
    "    else:\n",
    "        assert False, \"pay(380) при 100 баллах не выбросил ValueError\"\n",
    "    assert points.points == 100, \"после отказа баллы не должны меняться\"\n",
    "\n",
    "_check(test_pay, test_not_enough, names=[\"LoyaltyPoints.__init__\",\"LoyaltyPoints.pay\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "points-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class LoyaltyPoints(PaymentMethod):\n",
    "    def __init__(self, points):\n",
    "        super().__init__(\"баллы\")\n",
    "        self.points = points\n",
    "\n",
    "    def pay(self, amount):\n",
    "        if amount > self.points:\n",
    "            raise ValueError(\"недостаточно баллов\")\n",
    "        self.points -= amount\n",
    "        return f\"баллы: списано {amount}, осталось {self.points}\"\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "points-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Заказ"
   ],
   "id": "text-52"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 5. Заказ со способом оплаты\n",
    "\n",
    "Объявите класс `Order`: `Order(17, 640, method)` — номер `number`, сумма `total` и способ оплаты `method`\n",
    "(любой `PaymentMethod`); у нового заказа `paid = False`.\n",
    "\n",
    "- `pay()` просит способ оплаты заплатить `total`, ставит `paid = True` и возвращает строку чека\n",
    "  `\"заказ №17: наличные 1000 ₽, сдача 360 ₽\"`. Если способ оплаты выбросил `ValueError`, исключение\n",
    "  уходит дальше, а `paid` остаётся `False`.\n",
    "- Метод класса `from_line(line, method)` создаёт заказ из строки `\"17;640\"` — номер и сумма, оба целые.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "В `pay`: `receipt = self.method.pay(self.total)` — если тут выброшено исключение, следующие строки не выполнятся сами.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`from_line`: `number, total = line.split(\";\")` и `return cls(int(number), int(total), method)`.\n",
    "\n",
    "</details>"
   ],
   "id": "order-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Order:\n",
    "    def __init__(self, number, total, method):\n",
    "        ...\n",
    "\n",
    "    def pay(self):\n",
    "        ...\n",
    "\n",
    "    @classmethod\n",
    "    def from_line(cls, line, method):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "order"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 5 — выполните после своего решения\n",
    "def test_new():\n",
    "    \"новый заказ не оплачен\"\n",
    "    order = Order(17, 640, Cash(1000))\n",
    "    assert (getattr(order, \"number\", None), getattr(order, \"total\", None)) == (17, 640), \"номер и сумма — в атрибутах number и total\"\n",
    "    assert getattr(order, \"paid\", None) is False, \"у нового заказа paid = False\"\n",
    "\n",
    "\n",
    "def test_pay():\n",
    "    \"pay делегирует способу оплаты\"\n",
    "    order = Order(17, 640, Cash(1000))\n",
    "    got = order.pay()\n",
    "    assert got == \"заказ №17: наличные 1000 ₽, сдача 360 ₽\", f\"pay() вернул {got!r}\"\n",
    "    assert order.paid is True, \"после оплаты paid должен стать True\"\n",
    "\n",
    "\n",
    "def test_failed():\n",
    "    \"ValueError уходит дальше, paid остаётся False\"\n",
    "    order = Order(18, 640, Cash(500))\n",
    "    try:\n",
    "        order.pay()\n",
    "    except ValueError:\n",
    "        pass\n",
    "    else:\n",
    "        assert False, \"оплата наличными 500 ₽ за 640 ₽ прошла — pay заказа должен пропустить ValueError способа оплаты дальше\"\n",
    "    assert order.paid is False, \"оплата не прошла, а paid стал True — ставьте его только после успешной оплаты\"\n",
    "\n",
    "\n",
    "def test_from_line():\n",
    "    \"from_line — метод класса\"\n",
    "    assert isinstance(Order.__dict__.get(\"from_line\"), classmethod), \"from_line должен быть методом класса — @classmethod\"\n",
    "    cash = Cash(1000)\n",
    "    order = Order.from_line(\"17;640\", cash)\n",
    "    assert isinstance(order, Order), f\"from_line вернул {type(order).__name__}\"\n",
    "    assert (order.number, order.total) == (17, 640), f\"номер и сумма = {order.number!r}, {order.total!r}, а нужны числа 17 и 640\"\n",
    "    assert order.method is cash, \"способ оплаты — переданный объект\"\n",
    "\n",
    "_check(test_new, test_pay, test_failed, test_from_line, names=[\"Order.__init__\",\"Order.pay\",\"Order.from_line\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "order-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Order:\n",
    "    def __init__(self, number, total, method):\n",
    "        self.number = number\n",
    "        self.total = total\n",
    "        self.method = method\n",
    "        self.paid = False\n",
    "\n",
    "    def pay(self):\n",
    "        receipt = self.method.pay(self.total)\n",
    "        self.paid = True\n",
    "        return f\"заказ №{self.number}: {receipt}\"\n",
    "\n",
    "    @classmethod\n",
    "    def from_line(cls, line, method):\n",
    "        number, total = line.split(\";\")\n",
    "        return cls(int(number), int(total), method)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "order-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 4. День на кассе"
   ],
   "id": "text-67"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 6. Оплаты за день\n",
    "\n",
    "В `lines` — четыре заказа за утро, в `methods` — чем расплачивался каждый покупатель (первый и четвёртый —\n",
    "одной и той же картой `card`). Создайте заказы методом `from_line`, оплатите их по порядку и сохраните\n",
    "в `receipts` строки чеков оплаченных заказов, а в `failed` — номера заказов, оплата которых не прошла.\n",
    "Пройти два списка параллельно поможет встроенная `zip(lines, methods)`: она выдаёт пары «первый с первым,\n",
    "второй со вторым…».\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`for line, method in zip(lines, methods):` — пары «строка заказа, способ оплаты».\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`try: receipts.append(order.pay())`, `except ValueError: failed.append(order.number)`.\n",
    "\n",
    "</details>"
   ],
   "id": "day-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "card = Card(\"2200 1234 5678 9012\", 2000)\n",
    "lines = [\"17;1250\", \"18;640\", \"19;380\", \"20;1800\"]\n",
    "methods = [card, Cash(1000), LoyaltyPoints(500), card]\n",
    "receipts = ...\n",
    "failed = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "day"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 6 — выполните после своего решения\n",
    "def test_receipts():\n",
    "    \"receipts — чеки оплаченных\"\n",
    "    assert isinstance(receipts, list), f\"receipts — это {type(receipts).__name__}, а нужен список строк\"\n",
    "    assert receipts == [\n",
    "        \"заказ №17: карта *9012: списано 1250 ₽\",\n",
    "        \"заказ №18: наличные 1000 ₽, сдача 360 ₽\",\n",
    "        \"заказ №19: баллы: списано 380, осталось 120\",\n",
    "    ], f\"receipts = {receipts}\"\n",
    "\n",
    "\n",
    "def test_failed():\n",
    "    \"failed — номера неоплаченных\"\n",
    "    assert failed != [\"20\"], \"номер заказа — число 20, а не строка\"\n",
    "    assert failed != [], \"все заказы оплачены, а заказ №20 должен был не пройти: первый и четвёртый покупатель платят одной картой card — после заказа №17 на ней 750 ₽\"\n",
    "    assert failed == [20], f\"failed = {failed}, а не прошёл только заказ №20 — на карте осталось 750 ₽\"\n",
    "    assert card.balance == 750, f\"на карте осталось {card.balance} ₽, а должно 750 — оплачивайте каждый заказ один раз\"\n",
    "\n",
    "_check(test_receipts, test_failed, names=[\"receipts\",\"failed\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "day-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "card = Card(\"2200 1234 5678 9012\", 2000)\n",
    "lines = [\"17;1250\", \"18;640\", \"19;380\", \"20;1800\"]\n",
    "methods = [card, Cash(1000), LoyaltyPoints(500), card]\n",
    "receipts = []\n",
    "failed = []\n",
    "for line, method in zip(lines, methods):\n",
    "    order = Order.from_line(line, method)\n",
    "    try:\n",
    "        receipts.append(order.pay())\n",
    "    except ValueError:\n",
    "        failed.append(order.number)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "day-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итог"
   ],
   "id": "text-80"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Касса за утро\n",
    "print(\"Касса за утро\")\n",
    "for line in receipts:\n",
    "    print(\" \", line)\n",
    "print(\"не оплачены:\", \", \".join(f\"№{n}\" for n in failed))\n",
    "print(f\"на карте *9012 осталось {card.balance} ₽\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "summary"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "Три заказа из четырёх оплачены. Заказ №20 не прошёл: та же карта уже заплатила за заказ №17, и на счёте\n",
    "осталось 750 ₽ — меньше 1800. Касса узнала об этом из `ValueError`, а не проверкой «какой это способ оплаты»."
   ],
   "id": "component-85"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Обратите внимание, сколько кода касса знает о способах оплаты: ни одного `isinstance`. Новый способ —\n",
    "скажем, оплата по QR-коду — это ещё один подкласс `PaymentMethod`, а `Order` и цикл дня не меняются.\n",
    "Абстрактный класс гарантирует, что у нового способа будет `pay`, иначе его не создать."
   ],
   "id": "text-89"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
