Наше хранилище товаров уже умеет немало: CRUD-операции, нормализацию имён и цен, красивую таблицу и консоль администратора. Но устроено оно по-прежнему «на коленке» — как список кортежей, — и пока каталог маленький, этого хватает. Но когда в каталоге появляются тысячи товаров, начинают проявляться проблемы:
- чтобы найти товар по идентификатору или проверить, не занято ли имя, надо пройти весь список целиком, и чем больше каталог, тем медленнее работает каждая операция;
- идентификатор вычисляется по содержимому хранилища («максимальный плюс один»): его нельзя выдать независимо от остальных товаров, а два хранилища (например, на разных серверах) легко выдадут одинаковые номера;
- поля кортежа различаются только позицией, поэтому в коде появляются «магические» индексы, а чтобы поменять одно поле, приходится пересобирать запись целиком.
Обычные решения этих проблем знакомы любой базе данных. Записи хранят как «ключ → значение» в хеш-таблице, где поиск по ключу не зависит от размера хранилища. Рядом держат индекс — в СУБД это, например, уникальный индекс по имени. А идентификаторы генерируют так, чтобы каждый узел мог выдавать их сам, не согласовывая номера с остальными: в части 1 вы уже писали для этого Snowflake, а UUID — самый распространённый стандартный вариант.
Именно это мы и напишем: переведём хранилище на словари, заведём
UUID-идентификаторы и отдельный индекс имён, научим update_product
менять только нужные поля и оформим корзину покупателя тоже в виде словаря.
Получившееся хранилище станет основой, на которую опирается дальнейшая работа над магазином.
Часть 4 продолжает часть 3: тот же интернет-магазин, те же CRUD-операции, та же нормализация имён и цен, та же отрисовка таблицей и та же консоль администратора. То, что в этой части не меняется, берите из своего решения части 3. Меняется устройство данных:
- хранилище — уже не список кортежей, а словарь «идентификатор → запись», а сама запись — словарь с именованными полями;
- идентификатор товара — строка с UUID, а не порядковое целое число;
- рядом с хранилищем передаётся индекс имён — множество нормализованных имён, которые сейчас есть в хранилище. Проверка уникальности имени идёт по нему, без перебора всех записей;
update_productможет менять любую часть полей, а не все три сразу;- корзина — тоже словарь «идентификатор товара → количество»; модуль
cart.pyиз части 2, который в части 3 не использовался, снова входит в задание.
Модули:
storage.py— новая форма записи и новые константы;utils.py— логика без изменений относительно части 3 (PRICE_PRECISION,PRICE_STEP,TABLE_HEADERS,normalize_price,normalize_product_name,get_storage_str_representation) — используйте своё решение части 3; меняется только тип хранилища (см. ниже);crud.py— все функции дорабатываются под новое хранилище и индекс имён;cart.py— корзина из части 2, переделанная под словари;run_admin.py— консоль из части 3, переделанная под новые сигнатуры.
Если поведение не менялось, оно здесь не расписывается: реализуйте его так, как в своём решении части 3 (для корзины — части 2), см. описание части 3 и описание части 2.
Как и раньше, при неудаче функции crud.py и cart.py печатают сообщение в
stdout, возвращают None и ничего не меняют.
storage: dict[str, Product] — словарь, где ключ — идентификатор товара (строка
с UUID, например "3f2c…-…"), а значение — запись Product. Идентификатор
хранится только в ключе, внутри записи его нет.
Запись Product — словарь ровно с тремя ключами:
| Ключ | Тип | Что хранит |
|---|---|---|
"name" |
str |
имя товара в нормализованной форме, уникальное в пределах хранилища |
"price" |
Decimal |
цена за единицу, округлённая через normalize_price |
"quantity" |
int |
сколько единиц товара на складе |
product_names: set[str] — множество нормализованных имён всех товаров,
которые сейчас лежат в хранилище. Индекс всегда должен совпадать с набором
имён в storage. Каждая функция, которая меняет хранилище, получает индекс
вторым аргументом и после каждого успешного вызова поддерживает его в
актуальном виде. Хранилище и индекс передаются двумя отдельными аргументами,
в один объект их не объединяют.
cart: dict[str, int] — словарь «идентификатор товара → количество единиц в
корзине». Товары с нулевым количеством в корзине не хранятся: если количество
падает до нуля, ключ удаляется.
Product— псевдоним типаdict[str, Any].NAME_KEY,PRICE_KEY,QUANTITY_KEY— имена полей записи:"name","price","quantity". Каждая константа аннотированаtyping.Final. Код обращается к полям записи только через эти константы, а не через строковые литералы.
По сравнению с частью 3 константы PRODUCT_ID_INDEX, NAME_INDEX,
PRICE_INDEX, QUANTITY_INDEX и PRODUCT_ID_MIN больше не нужны — не
определяйте их.
Поведение всех функций прежнее. Меняется только тип хранилища в
get_storage_str_representation(storage: dict[str, Product]) -> str:
- в столбец
IDпопадает ключ словаря (строка UUID, выводится как есть); столбцыname,price,quantityберутся из соответствующих полей записи (priceиquantity— черезstr(...)); - строки товаров идут в порядке вставки в словарь;
- формат таблицы, расчёт ширины столбцов и вид пустой таблицы — как в части 3.
- Вход: ничего. Хранилище больше не передаётся.
- Выход: новый случайный UUID в виде строки. Результат можно разобрать
через
uuid.UUID(...), а два вызова дают разные значения. Идентификатор не зависит ни от порядка вставки, ни от уже занятых идентификаторов.
Подсказка. Не придумывайте генерацию сами: в стандартной библиотеке есть модуль
uuid, а в нём функцияuuid4(), которая возвращает случайный UUID. Не забудьте превратить результат в строку.
Примеры. Сами значения случайны, поэтому в комментариях указаны не они, а свойства результата:
product_id = generate_product_id()
print(product_id) # например: 3f2c9a4e-8b1d-4c7a-9e55-0d6a1b7c2f10
print(type(product_id)) # <class 'str'>
print(len(product_id)) # 36
print(uuid.UUID(product_id).version) # 4 (строка разбирается как UUID)Два вызова подряд дают разные идентификаторы:
first_id = generate_product_id()
second_id = generate_product_id()
print(first_id == second_id) # Falsecreate_product(storage: dict[str, Product], product_names: set[str], fields: dict[str, Any]) -> str | None
- Вход: хранилище, индекс имён и
fields— словарь, где обязательно есть все три ключа:NAME_KEY,PRICE_KEY(Decimal) иQUANTITY_KEY(int). - Выход: идентификатор нового товара или
None.
Шаги выполняются в таком порядке. Шаги 2 и 3 — проверки: если любая из них не проходит, хранилище и индекс не меняются.
- Имя пропускается через
normalize_product_name. - Если нормализованное имя пустое, печатается
product name must not be blankи возвращаетсяNone. - Если нормализованное имя уже есть в
product_names, печатаетсяproduct name '<имя>' is already takenи возвращаетсяNone. Имена сравниваются по нормализованной форме, поэтому" Cordless Drill "конфликтует с"cordless drill". - Иначе через
generate_product_id()создаётся новый идентификатор, и под ним вstorageкладётся запись{name: нормализованное имя, price: normalize_price(цена), quantity: количество}. Нормализованное имя добавляется вproduct_names, функция возвращает идентификатор.
Примеры. Каждый пример — отдельный фрагмент скрипта, который начинается с пустого хранилища и пустого индекса. Идентификатор случаен, поэтому у вас в выводе будет другой UUID — это нормально.
Успешное создание: имя нормализуется, цена округляется, имя попадает в индекс:
storage: dict[str, Product] = {}
product_names: set[str] = set()
product_id = create_product(
storage,
product_names,
{NAME_KEY: " Cordless Drill ", PRICE_KEY: Decimal("89.999"), QUANTITY_KEY: 12},
)
print(product_id) # 3f2c9a4e-8b1d-4c7a-9e55-0d6a1b7c2f10
print(storage)
# {
# '3f2c9a4e-8b1d-4c7a-9e55-0d6a1b7c2f10': {
# 'name': 'cordless drill',
# 'price': Decimal('90.00'),
# 'quantity': 12
# }
# }
print(product_names) # {'cordless drill'}Имя уже есть в индексе (сравнение идёт по нормализованной форме), хранилище и индекс не меняются:
storage: dict[str, Product] = {}
product_names: set[str] = set()
create_product(
storage,
product_names,
{NAME_KEY: "cordless drill", PRICE_KEY: Decimal("50"), QUANTITY_KEY: 3},
)
result = create_product(
storage,
product_names,
{NAME_KEY: " CORDLESS DRILL ", PRICE_KEY: Decimal("75"), QUANTITY_KEY: 1},
)
# печатает: product name 'cordless drill' is already taken
print(result) # None
print(len(storage)) # 1
print(product_names) # {'cordless drill'}- Вход: хранилище и идентификатор (строка).
- Выход: запись, которая лежит под этим ключом. Если такого ключа нет,
печатается
no product with id <id>и возвращаетсяNone.
update_product(storage: dict[str, Product], product_names: set[str], product_id: str, fields: dict[str, Any]) -> Product | None
Частичное обновление: в fields может быть любое подмножество ключей
NAME_KEY, PRICE_KEY и QUANTITY_KEY. Поля без ключа в fields сохраняют
текущее значение, поэтому, например, можно поменять одно количество, не
передавая имя и цену. Идентификатор (ключ в storage) не меняется.
Шаги выполняются в таком порядке. Шаги 1 и 2 содержат проверки: если любая из них не проходит, хранилище и индекс не меняются. Порядок изменился по сравнению с частью 3: сначала проверяется, есть ли товар с таким идентификатором.
- Если ключа
product_idвstorageнет, печатаетсяno product with id <id>и возвращаетсяNone. - Если в
fieldsесть имя, оно нормализуется черезnormalize_product_name.- Если нормализованное имя пустое, печатается
product name must not be blankи возвращаетсяNone. - Если нормализованное имя отличается от текущего имени товара и уже есть
в
product_names, печатаетсяproduct name '<имя>' is already takenи возвращаетсяNone. Переименование товара в его же текущее имя конфликтом не считается.
- Если нормализованное имя пустое, печатается
- Если в
fieldsесть цена, она округляется черезnormalize_price. - Поля из
fieldsзаписываются в запись товара. Если имя изменилось, старое имя удаляется изproduct_names, а новое добавляется. Обновление без переименования индекс не меняет. - Возвращается обновлённая запись, то есть тот же объект, который лежит в
storage[product_id].
Побочный эффект: переданный словарь fields меняется на месте. Имя и цена в
нём заменяются нормализованными значениями.
Пример. Сначала меняется одно количество, затем товар переименовывается вместе с новой ценой. Идентификатор случаен, поэтому у вас в выводе будет другой UUID — это нормально:
storage: dict[str, Product] = {}
product_names: set[str] = set()
product_id = create_product(
storage,
product_names,
{NAME_KEY: "Cordless Drill", PRICE_KEY: Decimal("89.99"), QUANTITY_KEY: 12},
)
print(product_id) # 3f2c9a4e-8b1d-4c7a-9e55-0d6a1b7c2f10
# Частичное обновление: меняется только количество, индекс имён остаётся прежним
print(update_product(storage, product_names, product_id, {QUANTITY_KEY: 7}))
# {
# 'name': 'cordless drill',
# 'price': Decimal('89.99'),
# 'quantity': 7
# }
print(product_names) # {'cordless drill'}
# Переименование: имя и цена нормализуются, в индексе старое имя заменяется новым
print(
update_product(
storage,
product_names,
product_id,
{NAME_KEY: " Hammer Drill ", PRICE_KEY: Decimal("109.999")},
)
)
# {
# 'name': 'hammer drill',
# 'price': Decimal('110.00'),
# 'quantity': 7
# }
print(product_names) # {'hammer drill'}- Вход: хранилище, индекс имён, идентификатор.
- Выход:
product_id, если товар был удалён. В этом случае его имя убирается изproduct_names. Если ключа нет, печатаетсяno product with id <id>и возвращаетсяNone; хранилище и индекс не меняются.
По сравнению с частью 2 здесь нет типа CartLine и констант LINE_*_INDEX:
корзина — обычный словарь dict[str, int] (см. «Формат данных»). Вспомогательные
функции можно добавлять на своё усмотрение. Обе функции возвращают теперь не
строку корзины, а количество единиц товара в корзине после операции. Обе
принимают индекс имён вместе с хранилищем. Сам индекс при этом не меняется:
при изменении одного количества имя товара не меняется.
add_to_cart(storage: dict[str, Product], product_names: set[str], cart: dict[str, int], product_id: str, quantity: int) -> int | None
Переносит quantity единиц товара со склада в корзину. Проверки идут в таком
порядке (если какая-то не проходит, склад и корзина не меняются):
- Если товара с таким идентификатором нет в хранилище, печатается
no product with id <id>и возвращаетсяNone. - Если на складе строго меньше
quantityединиц, печатаетсяnot enough stock for product <id>: <на складе> available, <quantity> requestedи возвращаетсяNone. Можно запросить ровно столько, сколько есть на складе: тогда остаток станет0, но запись останется в хранилище.
Если обе проверки пройдены, количество товара на складе уменьшается на
quantity, остальные поля записи не меняются. В корзине количество этого
товара увеличивается на quantity; если товара в корзине не было, для него
появляется ключ. Возвращается новое количество товара в корзине.
remove_from_cart(storage: dict[str, Product], product_names: set[str], cart: dict[str, int], product_id: str, quantity: int) -> int | None
Переносит quantity единиц товара из корзины обратно на склад. Проверки идут
в таком порядке (если какая-то не проходит, склад и корзина не меняются):
- Если
product_idнет в корзине, печатаетсяproduct <id> is not in the cartи возвращаетсяNone. - Если в корзине строго меньше
quantityединиц, печатаетсяcart holds only <в корзине> unit(s) of product <id>, cannot remove <quantity>и возвращаетсяNone. - Если товара с таким идентификатором нет в хранилище, печатается
no product with id <id>и возвращаетсяNone.
Если все три проверки пройдены, количество товара на складе увеличивается на
quantity, остальные поля записи не меняются, а в корзине количество
уменьшается на quantity. Если в корзине осталось 0, ключ удаляется.
Возвращается оставшееся в корзине количество; 0 значит, что товар из корзины
убран.
Набор команд, формат ввода, константы PROMPT и HELP_TEXT, функции
show_help и print_result, сообщения '<строка>' is not a command и
'<строка>' names a command but its arguments are invalid, а также обработка
ValueError и decimal.InvalidOperation такие же, как в части 3. Отличия:
run_command(storage: dict[str, Product], product_names: set[str], line: str) -> boolдополнительно принимает индекс имён и передаёт его вcreate_product,update_productиdelete_product.<id>в командахread,updateиdeleteне приводится кint, а передаётся строкой как есть. Поэтому неверный идентификатор уже не вызывает исключение:crud.pyпросто печатаетno product with id <id>.- Для
createиupdateаргументы собираются в словарь{NAME_KEY: имя, PRICE_KEY: Decimal(цена), QUANTITY_KEY: int(количество)}. Консольныйupdateвсегда передаёт все три поля. main()создаёт пустое хранилище{}и пустой индексset(), держит их в памяти всю сессию и передаёт в каждый вызовrun_command. Остальное поведение цикла такое же, как в части 3.
product_namesвсегда равно множеству{запись["name"] for запись in storage.values()}: каждый успешныйcreate_product,update_productс переименованием иdelete_productподдерживает это равенство, а неуспешный вызов не меняет ни хранилище, ни индекс.- Все сохранённые имена нормализованы и уникальны с точностью до нормализации.
- Все сохранённые цены прошли через
normalize_price. - Для успешно созданного товара
read_product(storage, id)возвращает{"name": normalize_product_name(name), "price": normalize_price(price), "quantity": quantity}. - После
delete_product(storage, product_names, id)вызовread_product(storage, id)возвращаетNone. - Количество сохраняется: после успешного
add_to_cartилиremove_from_cartсумма «на складе + в корзине» для этого товара не меняется. Если послеadd_to_cartвызватьremove_from_cartс тем жеquantity, склад и корзина вернутся в исходное состояние.