{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Итоговый проект: каталог и корзина\n",
    "\n",
    "Первая часть кассы «Кофейной лавки»: свои исключения, товар-dataclass, каталог-контейнер, загрузка каталога из строк и корзина, которая берёт товары из каталога.\n",
    "\n",
    "Курс ООП в Python · урок `oop-final-model` · [урок на сайте](https://ds-edu-trainer.vercel.app/courses/oop/final-model).\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",
    "\n",
    "| Часть кассы | Что это | Инструмент |\n",
    "| --- | --- | --- |\n",
    "| ошибки | своя иерархия | подклассы `Exception` |\n",
    "| товар | неизменяемые данные | `@dataclass(frozen=True)` |\n",
    "| каталог | контейнер товаров | `__len__`, `__contains__`, `__getitem__`, `__iter__` |\n",
    "| корзина | содержит каталог и строки | композиция, свойство, магические методы |\n",
    "\n",
    "## Шаг 1. Ошибки кассы"
   ],
   "id": "text-10"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 1. ShopError и UnknownProductError\n",
    "\n",
    "Объявите базовое исключение кассы `ShopError` (подкласс `Exception`) и его подкласс `UnknownProductError` —\n",
    "«товара нет в каталоге».\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Два класса; тело — `pass`.\n",
    "\n",
    "</details>"
   ],
   "id": "errors-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# объявите ShopError и UnknownProductError"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "errors"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 1 — выполните после своего решения\n",
    "def test_hierarchy():\n",
    "    \"ShopError → UnknownProductError\"\n",
    "    shop, unknown = globals().get(\"ShopError\"), globals().get(\"UnknownProductError\")\n",
    "    assert isinstance(shop, type) and isinstance(unknown, type), \"объявите оба класса: ShopError и UnknownProductError\"\n",
    "    assert issubclass(shop, Exception), \"ShopError должен наследовать от Exception\"\n",
    "    assert issubclass(unknown, shop), \"UnknownProductError должен наследовать от ShopError\"\n",
    "\n",
    "_check(test_hierarchy)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "errors-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class ShopError(Exception):\n",
    "    pass\n",
    "\n",
    "\n",
    "class UnknownProductError(ShopError):\n",
    "    pass\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "errors-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 2. Товар"
   ],
   "id": "text-32"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 2. Product\n",
    "\n",
    "Объявите неизменяемый dataclass `Product` с полями `name` (строка), `price` (целое) и `category` (строка, например\n",
    "`\"кофе\"`). Цена не больше нуля — `ValueError` при создании. `str(product)` — `\"Латте — 220 ₽\"`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "`@dataclass(frozen=True)`, проверка — в `__post_init__`, текст — в `__str__`.\n",
    "\n",
    "</details>"
   ],
   "id": "product-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "from dataclasses import dataclass, field\n",
    "\n",
    "\n",
    "class Product:\n",
    "    ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "product"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 2 — выполните после своего решения\n",
    "from dataclasses import fields, is_dataclass\n",
    "\n",
    "\n",
    "def test_fields():\n",
    "    \"frozen dataclass с тремя полями\"\n",
    "    assert is_dataclass(Product), \"Product — не dataclass\"\n",
    "    assert [f.name for f in fields(Product)] == [\"name\", \"price\", \"category\"], f\"поля: {[f.name for f in fields(Product)]}\"\n",
    "    latte = Product(\"Латте\", 240, \"кофе\")\n",
    "    try:\n",
    "        latte.price = 1\n",
    "    except AttributeError:\n",
    "        pass\n",
    "    else:\n",
    "        assert False, \"цену удалось изменить — нужен @dataclass(frozen=True)\"\n",
    "    assert len({latte, Product(\"Латте\", 240, \"кофе\")}) == 1, \"одинаковые товары должны быть равны и хешироваться одинаково\"\n",
    "\n",
    "\n",
    "def test_check_and_str():\n",
    "    \"цена > 0 и str\"\n",
    "    try:\n",
    "        Product(\"Чай\", 0, \"чай\")\n",
    "    except ValueError:\n",
    "        pass\n",
    "    else:\n",
    "        assert False, \"Product(\\\"Чай\\\", 0, \\\"чай\\\") создался — нужен ValueError в __post_init__\"\n",
    "    assert str(Product(\"Латте\", 240, \"кофе\")) == \"Латте — 240 ₽\", f\"str = {str(Product('Латте', 240, 'кофе'))!r}\"\n",
    "\n",
    "_check(test_fields, test_check_and_str)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "product-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "from dataclasses import dataclass, field\n",
    "\n",
    "\n",
    "@dataclass(frozen=True)\n",
    "class Product:\n",
    "    name: str\n",
    "    price: int\n",
    "    category: str\n",
    "\n",
    "    def __post_init__(self):\n",
    "        if self.price <= 0:\n",
    "            raise ValueError(f\"цена должна быть больше нуля: {self.name}\")\n",
    "\n",
    "    def __str__(self):\n",
    "        return f\"{self.name} — {self.price} ₽\"\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "product-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 3. Каталог"
   ],
   "id": "text-41"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 3. Catalog\n",
    "\n",
    "Объявите класс `Catalog`: `Catalog([товар, товар, …])` хранит товары во внутреннем словаре «название → товар».\n",
    "\n",
    "- `len(catalog)` — число товаров;\n",
    "- `\"Латте\" in catalog` — есть ли товар с таким названием;\n",
    "- `catalog[\"Латте\"]` — товар; если его нет — `UnknownProductError(\"нет в каталоге: Латте\")`;\n",
    "- `for product in catalog` — товары по алфавиту названий.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "В `__init__`: `self._products = {p.name: p for p in products}`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "В `__getitem__` сначала проверьте `name not in self._products` и выбросите своё исключение — иначе словарь выбросит `KeyError`.\n",
    "\n",
    "</details>"
   ],
   "id": "catalog-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Catalog:\n",
    "    def __init__(self, products):\n",
    "        ...\n",
    "\n",
    "    def __len__(self):\n",
    "        ...\n",
    "\n",
    "    def __contains__(self, name):\n",
    "        ...\n",
    "\n",
    "    def __getitem__(self, name):\n",
    "        ...\n",
    "\n",
    "    def __iter__(self):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "catalog"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 3 — выполните после своего решения\n",
    "def _catalog():\n",
    "    return Catalog([Product(\"Раф\", 260, \"кофе\"), Product(\"Чай\", 120, \"чай\"), Product(\"Латте\", 240, \"кофе\")])\n",
    "\n",
    "\n",
    "def test_len_in():\n",
    "    \"len и in\"\n",
    "    c = _catalog()\n",
    "    assert len(c) == 3, f\"len(catalog) = {len(c)}, а товаров 3\"\n",
    "    assert (\"Латте\" in c) is True and (\"Какао\" in c) is False, \"in проверяет, есть ли товар с таким названием\"\n",
    "\n",
    "\n",
    "def test_getitem():\n",
    "    \"товар по названию и UnknownProductError\"\n",
    "    c = _catalog()\n",
    "    got = c[\"Чай\"]\n",
    "    assert isinstance(got, Product) and got.name == \"Чай\", f\"catalog[\\\"Чай\\\"] = {got!r}\"\n",
    "    try:\n",
    "        c[\"Какао\"]\n",
    "    except UnknownProductError as e:\n",
    "        assert str(e) == \"нет в каталоге: Какао\", f\"сообщение {str(e)!r}\"\n",
    "    except KeyError:\n",
    "        assert False, \"выброшен KeyError, а нужен UnknownProductError — проверьте название до обращения к словарю\"\n",
    "    else:\n",
    "        assert False, \"catalog[\\\"Какао\\\"] не выбросил UnknownProductError\"\n",
    "\n",
    "\n",
    "def test_iter():\n",
    "    \"товары по алфавиту\"\n",
    "    names = [p.name for p in _catalog()]\n",
    "    assert names == [\"Латте\", \"Раф\", \"Чай\"], f\"цикл по каталогу дал {names}, а нужно по алфавиту\"\n",
    "\n",
    "_check(test_len_in, test_getitem, test_iter, names=[\"Catalog.__init__\",\"Catalog.__len__\",\"Catalog.__contains__\",\"Catalog.__getitem__\",\"Catalog.__iter__\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "catalog-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Catalog:\n",
    "    def __init__(self, products):\n",
    "        self._products = {p.name: p for p in products}\n",
    "\n",
    "    def __len__(self):\n",
    "        return len(self._products)\n",
    "\n",
    "    def __contains__(self, name):\n",
    "        return name in self._products\n",
    "\n",
    "    def __getitem__(self, name):\n",
    "        if name not in self._products:\n",
    "            raise UnknownProductError(f\"нет в каталоге: {name}\")\n",
    "        return self._products[name]\n",
    "\n",
    "    def __iter__(self):\n",
    "        for name in sorted(self._products):\n",
    "            yield self._products[name]\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "catalog-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 4. Каталог из строк"
   ],
   "id": "text-55"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 4. Загрузка каталога\n",
    "\n",
    "Напишите функцию `load_catalog(lines)`: из строк вида `\"Латте;220;кофе\"` она собирает `Catalog`. Затем загрузите\n",
    "каталог кофейни из `menu_lines` в переменную `catalog`.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Для каждой строки — `name, price, category = line.split(\";\")` и `Product(name, int(price), category)`.\n",
    "\n",
    "</details>"
   ],
   "id": "load-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "def load_catalog(lines):\n",
    "    ...\n",
    "\n",
    "\n",
    "menu_lines = [\n",
    "    \"Эспрессо;150;кофе\", \"Капучино;220;кофе\", \"Латте;240;кофе\", \"Раф;260;кофе\",\n",
    "    \"Чай;120;чай\", \"Какао;190;другое\", \"Круассан;140;выпечка\", \"Маффин;130;выпечка\",\n",
    "]\n",
    "catalog = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 4 — выполните после своего решения\n",
    "def test_function():\n",
    "    \"load_catalog собирает Catalog\"\n",
    "    got = load_catalog([\"Чай;120;чай\"])\n",
    "    assert isinstance(got, Catalog), f\"load_catalog вернула {type(got).__name__}, а нужен Catalog\"\n",
    "    tea = got[\"Чай\"]\n",
    "    assert tea == Product(\"Чай\", 120, \"чай\"), f\"из строки \\\"Чай;120;чай\\\" получилось {tea!r} — цену переведите int()\"\n",
    "\n",
    "\n",
    "def test_catalog():\n",
    "    \"catalog — меню кофейни\"\n",
    "    assert isinstance(catalog, Catalog), f\"catalog — это {type(catalog).__name__}, а нужен Catalog: load_catalog(menu_lines)\"\n",
    "    assert len(catalog) == 8, f\"в каталоге {len(catalog)} товаров, а строк меню 8\"\n",
    "    assert catalog[\"Маффин\"].category == \"выпечка\", \"категория маффина — выпечка\"\n",
    "\n",
    "_check(test_function, test_catalog, names=[\"catalog\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "load-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "def load_catalog(lines):\n",
    "    products = []\n",
    "    for line in lines:\n",
    "        name, price, category = line.split(\";\")\n",
    "        products.append(Product(name, int(price), category))\n",
    "    return Catalog(products)\n",
    "\n",
    "\n",
    "menu_lines = [\n",
    "    \"Эспрессо;150;кофе\", \"Капучино;220;кофе\", \"Латте;240;кофе\", \"Раф;260;кофе\",\n",
    "    \"Чай;120;чай\", \"Какао;190;другое\", \"Круассан;140;выпечка\", \"Маффин;130;выпечка\",\n",
    "]\n",
    "catalog = load_catalog(menu_lines)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "load-solution"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Каталог по алфавиту\n",
    "for product in catalog:\n",
    "    print(f\"{product} ({product.category})\")"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "look"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 5. Корзина\n",
    "\n",
    "Строка корзины — товар и количество. Это маленький dataclass, он уже готов:"
   ],
   "id": "text-66"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Строка корзины\n",
    "from dataclasses import dataclass\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",
    "print(Line(catalog[\"Латте\"], 2), Line(catalog[\"Латте\"], 2).cost)"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "line"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 5. Cart\n",
    "\n",
    "Объявите класс `Cart`: `Cart(catalog)` — пустая корзина, которая **содержит** каталог и берёт товары из него.\n",
    "\n",
    "- `add(name, qty=1)` — найти товар в каталоге (неизвестный — `UnknownProductError` уходит дальше) и добавить\n",
    "  строку; если строка с этим товаром уже есть — увеличить её количество, а не добавлять вторую;\n",
    "- `len(cart)` — число строк, `for line in cart` — строки по порядку добавления;\n",
    "- свойство `subtotal` — сумма `cost` всех строк.\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Строки — список `self._lines`; для существующего товара найдите его строку циклом и прибавьте `qty`.\n",
    "\n",
    "</details>\n",
    "<details><summary>Подсказка 2</summary>\n",
    "\n",
    "`Line` изменяемый — `line.qty += qty` работает.\n",
    "\n",
    "</details>"
   ],
   "id": "cart-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "class Cart:\n",
    "    def __init__(self, catalog):\n",
    "        ...\n",
    "\n",
    "    def add(self, name, qty=1):\n",
    "        ...\n",
    "\n",
    "    def __len__(self):\n",
    "        ...\n",
    "\n",
    "    def __iter__(self):\n",
    "        ...\n",
    "\n",
    "    @property\n",
    "    def subtotal(self):\n",
    "        ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "cart"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 5 — выполните после своего решения\n",
    "def _cart():\n",
    "    return Cart(Catalog([Product(\"Латте\", 240, \"кофе\"), Product(\"Круассан\", 140, \"выпечка\")]))\n",
    "\n",
    "\n",
    "def test_add():\n",
    "    \"add и subtotal\"\n",
    "    cart = _cart()\n",
    "    assert len(cart) == 0 and cart.subtotal == 0, \"новая корзина пустая, subtotal = 0\"\n",
    "    cart.add(\"Латте\", 2)\n",
    "    cart.add(\"Круассан\")\n",
    "    assert len(cart) == 2, f\"в корзине {len(cart)} строк, а нужно 2\"\n",
    "    assert cart.subtotal == 620, f\"subtotal = {cart.subtotal!r}, а два латте и круассан стоят 620\"\n",
    "\n",
    "\n",
    "def test_merge():\n",
    "    \"тот же товар — одна строка\"\n",
    "    cart = _cart()\n",
    "    cart.add(\"Латте\", 2)\n",
    "    cart.add(\"Латте\")\n",
    "    lines = list(cart)\n",
    "    assert len(lines) == 1, f\"после двух add(\\\"Латте\\\") в корзине {len(lines)} строки — увеличьте количество существующей строки\"\n",
    "    assert lines[0].qty == 3, f\"у латте qty = {lines[0].qty}, а нужно 3\"\n",
    "\n",
    "\n",
    "def test_unknown():\n",
    "    \"неизвестный товар — UnknownProductError\"\n",
    "    cart = _cart()\n",
    "    try:\n",
    "        cart.add(\"Глинтвейн\")\n",
    "    except UnknownProductError:\n",
    "        pass\n",
    "    else:\n",
    "        assert False, \"cart.add(\\\"Глинтвейн\\\") не выбросил UnknownProductError — товар берётся из каталога: self.catalog[name]\"\n",
    "    assert len(cart) == 0, \"после ошибки корзина не должна меняться\"\n",
    "\n",
    "_check(test_add, test_merge, test_unknown, names=[\"Cart.__init__\",\"Cart.add\",\"Cart.__len__\",\"Cart.__iter__\",\"Cart.subtotal\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "cart-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "class Cart:\n",
    "    def __init__(self, catalog):\n",
    "        self.catalog = catalog\n",
    "        self._lines = []\n",
    "\n",
    "    def add(self, name, qty=1):\n",
    "        product = self.catalog[name]\n",
    "        for line in self._lines:\n",
    "            if line.product == product:\n",
    "                line.qty += qty\n",
    "                return\n",
    "        self._lines.append(Line(product, qty))\n",
    "\n",
    "    def __len__(self):\n",
    "        return len(self._lines)\n",
    "\n",
    "    def __iter__(self):\n",
    "        for line in self._lines:\n",
    "            yield line\n",
    "\n",
    "    @property\n",
    "    def subtotal(self):\n",
    "        return sum(line.cost for line in self._lines)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "cart-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Шаг 6. Первые заказы"
   ],
   "id": "text-84"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Упражнение 6. Три корзины\n",
    "\n",
    "По списку `requests` — что попросил каждый покупатель — соберите корзины. Сохраните в `subtotals` суммы корзин\n",
    "по порядку, а в `unknown` — названия, которых нет в каталоге (такие позиции пропускайте, остальное в корзину\n",
    "кладите).\n",
    "\n",
    "<details><summary>Подсказка 1</summary>\n",
    "\n",
    "Для каждого покупателя — новая `Cart(catalog)`; для каждой позиции — `try: cart.add(name, qty)`, `except UnknownProductError: unknown.append(name)`.\n",
    "\n",
    "</details>"
   ],
   "id": "orders-task"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "requests = [\n",
    "    [(\"Латте\", 2), (\"Круассан\", 2), (\"Латте\", 1)],\n",
    "    [(\"Капучино\", 1), (\"Глинтвейн\", 1), (\"Чай\", 2), (\"Маффин\", 1)],\n",
    "    [(\"Раф\", 1), (\"Какао\", 1)],\n",
    "]\n",
    "subtotals = ...\n",
    "unknown = ..."
   ],
   "execution_count": null,
   "outputs": [],
   "id": "orders"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Проверка упражнения 6 — выполните после своего решения\n",
    "def test_subtotals():\n",
    "    \"суммы корзин\"\n",
    "    assert isinstance(subtotals, list), f\"subtotals — это {type(subtotals).__name__}, а нужен список сумм\"\n",
    "    assert len(subtotals) == 3, f\"в subtotals {len(subtotals)} сумм, а покупателей 3 — пропускайте позицию, а не весь заказ\"\n",
    "    assert subtotals == [1000, 590, 450], f\"subtotals = {subtotals}, а нужно [1000, 590, 450]\"\n",
    "\n",
    "\n",
    "def test_unknown():\n",
    "    \"unknown — чего нет в каталоге\"\n",
    "    assert unknown == [\"Глинтвейн\"], f\"unknown = {unknown}\"\n",
    "\n",
    "_check(test_subtotals, test_unknown, names=[\"subtotals\",\"unknown\"])"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "orders-check"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<details><summary>Решение</summary>\n",
    "\n",
    "```python\n",
    "requests = [\n",
    "    [(\"Латте\", 2), (\"Круассан\", 2), (\"Латте\", 1)],\n",
    "    [(\"Капучино\", 1), (\"Глинтвейн\", 1), (\"Чай\", 2), (\"Маффин\", 1)],\n",
    "    [(\"Раф\", 1), (\"Какао\", 1)],\n",
    "]\n",
    "subtotals = []\n",
    "unknown = []\n",
    "for request in requests:\n",
    "    cart = Cart(catalog)\n",
    "    for name, qty in request:\n",
    "        try:\n",
    "            cart.add(name, qty)\n",
    "        except UnknownProductError:\n",
    "            unknown.append(name)\n",
    "    subtotals.append(cart.subtotal)\n",
    "```\n",
    "\n",
    "</details>"
   ],
   "id": "orders-solution"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Итог"
   ],
   "id": "text-94"
  },
  {
   "cell_type": "code",
   "metadata": {},
   "source": [
    "# Корзины\n",
    "print(f\"в каталоге {len(catalog)} товаров\")\n",
    "for i, total in enumerate(subtotals, 1):\n",
    "    print(f\"корзина {i}: {total} ₽\")\n",
    "print(\"нет в каталоге:\", \", \".join(unknown))"
   ],
   "execution_count": null,
   "outputs": [],
   "id": "summary"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*После решения всех упражнений урока:*\n",
    "\n",
    "Модель кассы готова: каталог из восьми товаров, три корзины на 1000, 590 и 450 ₽, а «Глинтвейна» в каталоге\n",
    "нет — касса не упала, а сообщила об этом исключением. В следующем уроке к этой модели добавятся скидки,\n",
    "оплата и чек."
   ],
   "id": "component-99"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "colab": {
   "provenance": []
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
