Проблема:
- Поиск товаров возвращал пустые результаты
- API /products/api/search-products-variants/ возвращает ID в формате 'product_123'
- Форма incoming ожидает числовой ID (123)
- Select2 не мог сохранить значение из-за несовпадения формата
Решение:
- Добавлена функция processResults в AJAX настройках Select2
- Извлекаем числовой ID из строки 'product_123' -> '123'
- Обрабатываем как группированные результаты, так и плоские
- Сохраняем остальные поля (text, sku, price, actual_price)
Логика обработки:
1. Проверяем наличие children (группа)
2. Если группа - обрабатываем каждый item в children
3. Если плоский список - обрабатываем напрямую
4. Используем .replace('product_', '') для извлечения ID
Теперь Select2 корректно:
- Показывает список товаров
- Сохраняет выбранное значение
- Отправляет числовой ID в форму
Проблема:
- На странице /inventory/incoming/create/ не работал поиск товаров
- Использовался обычный <select> с предзагруженным списком всех товаров
- При большом количестве товаров список был неудобным
- Невозможно было искать товары по названию в реальном времени
Решение:
- Заменён обычный <select> на Select2 с AJAX автокомплитом
- Подключен API endpoint /products/api/search-products-variants/
- Поиск товаров работает в реальном времени (с задержкой 250ms)
- Минимальная длина поиска: 0 символов (можно открыть весь список)
- Поддержка русского языка
- Theme: bootstrap-5 для визуального соответствия
Изменения:
- Удалён предзагруженный список товаров из контекста шаблона
- Добавлена инициализация Select2 для каждой новой строки товара
- При удалении строки вызывается select2('destroy') для очистки
- Исправлена логика восстановления товаров при ошибке валидации
- Используется Option API для программной установки значений
Технические детали:
- jQuery и Select2 уже подключены в base.html
- Не дублируем подключение библиотек
- Используем событие 'change' для Select2 вместо 'input'
- Кэширование AJAX запросов включено
Теперь поиск товаров работает корректно! 🎉
Проблема:
- При изменении количества в OrderItem для заказа в статусе 'completed'
- Создавался ДУБЛИКАТ резерва (старый converted_to_sale + новый reserved)
- Это приводило к двойному списанию товара со склада (-10 лишних единиц)
- Фильтр status='reserved' пропускал существующие резервы в других статусах
Сценарий бага:
1. Заказ выполнен: 20 шт → резерв 20 шт (converted_to_sale)
2. Увеличить на 10 шт (до 30) → создаётся НОВЫЙ резерв 30 шт (reserved)
3. Итого: 20 + 30 = 50 шт зарезервировано вместо 30!
4. При переводе обратно в 'completed' → двойное списание (50 вместо 30)
Решение:
- Убран фильтр status='reserved' из update_reservation_on_item_change
- Теперь резерв ищется по order_item независимо от статуса
- Обновляется ТОЛЬКО quantity, статус НЕ меняется
- Добавлен @transaction.atomic для атомарности операции
- Добавлено логирование всех операций с резервами
- Используется save(update_fields=['quantity']) для оптимизации
Безопасность решения:
- Резервы разных заказов НЕ конфликтуют (разные order_item)
- Один товар в разных заказах = разные OrderItem = разные Reservation
- Каждый OrderItem имеет уникальный резерв
- Дубликаты больше НЕ создаются
Изменённые файлы:
- inventory/signals.py (функция update_reservation_on_item_change)
- FIX_RESERVATION_DUPLICATE_BUG.md (полная документация бага и решения)
Покрытие всех сценариев:
✅ Создание заказа с товарами
✅ Добавление товара при редактировании
✅ Изменение количества (черновик)
✅ Изменение количества (выполнен) - ИСПРАВЛЕНО
✅ Повторное сохранение заказа
КРИТИЧНО: Это исправление влияет на учёт товара и требует тестирования!
Проблема:
- Фильтр по заказу не работал
- order_number в форме передается как строка (ORD-103 или 103)
- В модели Order поле order_number имеет тип PositiveIntegerField
- Не было преобразования строки в число
Исправление:
- Добавлен парсинг номера заказа:
* Если формат 'ORD-103' -> извлекается число 103
* Если просто '103' -> преобразуется в число 103
- Добавлена обработка ошибок (try-except)
- Добавлена фильтрация таблицы заказов (показывать только выбранный)
Теперь фильтр работает с любым форматом ввода:
- ORD-103 ✓
- ord-103 ✓
- 103 ✓
Проблема:
- В шаблоне использовалось несуществующее поле total_price
- Фактическое поле в модели Order называется total_amount
Исправление:
- Заменено order.total_price на order.total_amount
- Теперь итоговая сумма заказа отображается корректно
Проблема:
- В шаблоне использовалось несуществующее поле cost_per_unit
- Фактическое поле в модели StockBatch называется cost_price
Исправление:
- Заменено batch.cost_per_unit на batch.cost_price
- Теперь закупочная цена партии отображается корректно
Проблема:
- В шаблоне использовалось несуществующее поле cost_per_unit
- Фактическое поле в модели называется cost_price
Исправление:
- Заменено alloc.cost_per_unit на alloc.cost_price
- Теперь себестоимость за единицу отображается корректно
Реализация:
- Создан view debug_inventory_page (только для суперюзеров)
- URL: /inventory/debug/
- Компактный дизайн с минимальными отступами (10-11px)
- Ссылка 🔧 Debug в navbar (видна только суперюзерам)
Функционал:
1. Показывает полную картину инвентаризации на одной странице:
- Заказы (Order) - номер, статус, покупатель, is_returned
- Остатки (Stock) - доступно, зарезервировано, свободно
- Партии (StockBatch) - количество, активность, дата поступления
- Резервы (Reservation) - статус, заказ, все даты
- Продажи (Sale) - количество, цена продажи, заказ
- Списания (SaleBatchAllocation) - откуда списано, сколько
2. Фильтры:
- По товару (dropdown)
- По номеру заказа (текстовое поле)
- По складу (dropdown)
3. UI:
- Цветовая индикация статусов резервов
- Бейджи для ключевых данных
- Компактные таблицы Bootstrap
- Неактивные партии выделены красным
Исправления:
- Reservation.created_at → reserved_at (у модели нет created_at)
- Sale.created_at → date (дата операции хранится в поле date)
- Product.is_active → archived_at__isnull=True (используется soft delete)
- Удалена колонка себестоимости из Sale (это поле не хранится в модели)
Файлы:
- inventory/views/debug_views.py - новый view
- inventory/templates/inventory/debug_page.html - шаблон
- inventory/urls.py - добавлен роут
- templates/navbar.html - добавлена ссылка
Юзкейс:
Суперюзер принимает товар → оформляет заказ → меняет статусы →
переходит на /inventory/debug/ → видит полную картину изменений
Проблема:
- У модели Reservation нет поля created_at
- Есть поле reserved_at для даты создания резерва
Исправление:
- В view изменена сортировка order_by('-reserved_at')
- В шаблоне изменено отображение даты res.reserved_at
Реализация:
- Создан view debug_inventory_page (только для суперюзеров)
- URL: /inventory/debug/
- Компактный дизайн с минимальными отступами и маленьким шрифтом (10-11px)
Функционал:
1. Показывает полную картину инвентаризации на одной странице:
- Заказы (Order) - номер, статус, покупатель, is_returned
- Остатки (Stock) - доступно, зарезервировано, свободно
- Партии (StockBatch) - количество, активность, дата поступления
- Резервы (Reservation) - статус (reserved/converted_to_sale/released), заказ, даты
- Продажи (Sale) - количество, цены, заказ
- Списания (SaleBatchAllocation) - откуда списано, сколько
2. Фильтры:
- По товару (dropdown с названием и SKU)
- По номеру заказа (текстовое поле)
- По складу (dropdown)
- Кнопка 'Применить' и 'Сбросить'
3. UI:
- Цветовая индикация статусов резервов
- Бейджи для ключевых данных
- Компактные таблицы Bootstrap
- Неактивные партии выделены красным
- Ограничение в 100 записей на таблицу для производительности
4. Навигация:
- Ссылка 🔧 Debug в navbar (видна только суперюзерам)
- Красный цвет для видимости
Юзкейс:
Суперюзер принимает товар на склад → оформляет заказ → меняет статусы →
переходит на /inventory/debug/ → видит полную картину всех изменений
Файлы:
- inventory/views/debug_views.py - новый view
- inventory/templates/inventory/debug_page.html - шаблон
- inventory/urls.py - добавлен роут
- templates/navbar.html - добавлена ссылка для суперюзеров
Проблема:
- При смене статуса заказа ОТМЕНЁН → ВЫПОЛНЕН
- Sale создавался и товар списывался корректно ✓
- НО резервы оставались в статусе 'released' вместо 'converted_to_sale'
- Это приводило к некорректной истории и возможным проблемам при откате
Причина:
- Сигнал искал только резервы в статусе 'reserved'
- После отмены резервы были в статусе 'released'
- При повторном выполнении они не обновлялись
Решение:
- Изменён фильтр резервов: берём ВСЕ кроме 'converted_to_sale'
- Теперь обрабатываются резервы в любом статусе (reserved, released, и др.)
- Элегантное решение без хардкода конкретных статусов
Дополнительно:
- Добавлен @transaction.atomic к сигналам обновления Stock
- Защита от race conditions при одновременном изменении резервов
- Минимальные издержки, максимальная надёжность
Результат:
- Корректная работа при ЛЮБЫХ переходах статусов:
* reserved → converted_to_sale ✓
* released → converted_to_sale ✓
* повторный вызов → пропуск ✓
- Целостность данных гарантирована транзакциями
- Элегантный код без костылей
Проблема:
- При откате заказа из статуса 'completed' в 'возврат' или другой статус
- Резервы правильно обновлялись на 'reserved' или 'released'
- НО Stock.quantity_reserved не обновлялся
- В результате товар показывался как полностью свободный, хотя был резерв
Причина:
- В сигнале rollback_sale_on_status_change использовался .update()
- Это не вызывало сигнал update_stock_on_reservation_change
- Stock не пересчитывался автоматически
Решение:
- Заменен .update() на .save(update_fields=[...]) в сигнале отката
- Теперь при изменении резервов автоматически срабатывает сигнал
- Stock корректно обновляется в обоих направлениях:
* completed → резервы converted_to_sale → Stock обновляется
* откат → резервы reserved/released → Stock обновляется
- Убран костыль с ручным вызовом refresh_from_batches()
Результат:
- Элегантное единообразное решение для всех сценариев
- Stock автоматически синхронизируется с резервами
- Работает корректно при любых изменениях статуса заказа
Проблема:
- При смене статуса заказа на 'Выполнен' товар списывался со склада
- Резервы обновлялись на статус 'converted_to_sale'
- НО Stock.quantity_reserved не обновлялся автоматически
- В результате резервы продолжали 'держать' товар, хотя он уже продан
Решение:
1. Изменен сигнал create_sale_on_order_completion:
- Используется .save(update_fields=[...]) вместо .update()
- Это вызывает сигнал update_stock_on_reservation_change
- Убран костыль с ручным вызовом refresh_from_batches()
2. Оптимизирован сигнал update_stock_on_reservation_change:
- Stock обновляется ТОЛЬКО при изменении status или quantity
- При изменении других полей (даты и т.д.) Stock НЕ пересчитывается
- Предотвращены лишние пересчёты и улучшена производительность
3. Добавлены диагностические инструменты:
- check_stock_103.py - для диагностики проблем с Stock
- fix_stock_after_sale.py - команда для исправления старых заказов
- diagnose_reservation_issue.py - универсальная диагностика
Результат:
- Элегантное решение без дублирования логики
- Stock автоматически обновляется при изменении резервов
- Работает везде, не только в заказах
- Оптимизировано для производительности
Создан файл tenants/tests/test_tenant_creation.py с 7 E2E тестами:
1. test_new_tenant_gets_all_5_payment_methods (КРИТИЧЕСКИЙ)
- Проверяет что новый тенант получает все 5 способов оплаты
- Включая account_balance (основной баг который исправили)
- Проверяет правильность порядка, флагов, названий
2. test_new_tenant_gets_order_statuses
- Проверяет создание системных статусов заказов
- Минимум 3 статуса (draft, completed и другие)
3. test_new_tenant_gets_system_customer
- Проверяет создание системного клиента
- Для анонимных продаж
4. test_new_tenant_gets_superuser
- Проверяет создание суперпользователя
- С email из настроек TENANT_ADMIN_EMAIL
5. test_new_tenant_gets_domain
- Проверяет создание домена
- Формат: {schema_name}.localhost
6. test_registration_status_changes_to_approved
- Проверяет изменение статуса заявки
- PENDING → APPROVED
7. test_complete_tenant_onboarding (КОМПЛЕКСНЫЙ E2E)
- Проверяет весь процесс онбординга
- Все предыдущие проверки в одном тесте
- Красивый вывод результата в консоль
Особенности:
- TransactionTestCase для работы с реальными схемами
- Создание администратора в setUp для каждого теста
- Автоматическая очистка схем в tearDown
- Вызов реального метода _approve_registration из админки
- Полное тестирование процесса как в продакшене
Результат: 2 главных теста прошли успешно ✓
test_new_tenant_gets_all_5_payment_methods: OK (10s)
test_complete_tenant_onboarding: OK (9.5s)
Создан файл orders/tests/test_payment_methods.py с комплексными тестами:
1. PaymentMethodCreationTest (6 тестов)
- Проверка создания всех 5 способов оплаты через команду
- Проверка системных флагов и активности
- Проверка правильности порядка сортировки
- Проверка идемпотентности команды
- Критический тест наличия account_balance
2. PaymentMethodMultiTenantTest (2 теста)
- Проверка изоляции данных между тенантами
- Проверка кастомных способов оплаты в разных тенантах
3. PaymentMethodTransactionTest (7 тестов)
- Проверка связи PaymentMethod.transactions
- Проверка создания транзакций
- Проверка изоляции транзакций по способам оплаты
- Проверка защиты от удаления (PROTECT)
- Критический тест использования account_balance
- Исправление бага obj.payments → obj.transactions
4. PaymentMethodOrderingTest (2 теста)
- Проверка сортировки по полю order
- Проверка что account_balance первый (order=0)
Особенности тестирования:
- Использование TenantTestCase для изоляции тенантов
- Использование TransactionTestCase для мультитенантных тестов
- Ручное создание/удаление схем для безопасности
- Проверка изоляции данных между схемами
Результат: 15 тестов, все прошли успешно ✓
Проблема #1: Дублирование кода способов оплаты
- В tenants/admin.py был полный список способов оплаты (45 строк)
- В orders/management/commands/create_payment_methods.py был другой список
- При создании тенанта отсутствовал способ 'account_balance'
- Нарушение DRY принципа
Решение: Single Source of Truth
- Единственный источник истины: команда create_payment_methods
- В tenants/admin.py заменено дублирование на call_command()
- Удалено 45 строк дублирующего кода
- Теперь все тенанты получают одинаковый полный список
Проблема #2: AttributeError в админке PaymentMethod
- obj.payments.count() вызывал ошибку
- В модели Transaction связь называется 'transactions', а не 'payments'
Решение: Исправлено в orders/admin.py
- obj.payments → obj.transactions (2 места)
- Админка PaymentMethod теперь работает корректно
Для тенанта buba:
- Создан скрипт add_payment_methods_to_buba.py
- Добавлен недостающий способ оплаты 'С баланса счёта'
Изменённые файлы:
- myproject/tenants/admin.py - вызов команды вместо дублирования
- myproject/orders/admin.py - исправлено на transactions
- add_payment_methods_to_buba.py - скрипт для существующих тенантов
Проблема #1: Резервы не создавались при создании заказа
- Order сохранялся БЕЗ items → сигнал reserve_stock_on_order_create
не мог создать резервы (items.all() был пустой)
- OrderItem создавались ПОСЛЕ, но сигнал уже отработал
Решение #1: Создание резервов через сигнал OrderItem
- Доработан сигнал update_reservation_on_item_change
- Убрано раннее возвращение для created=True
- Теперь резервы создаются при добавлении OrderItem (любым способом)
- Работает для всех сценариев:
* Создание заказа с товарами
* Добавление товаров при редактировании
* Изменение количества
Проблема #2: Риск расхождений при удалении заказа
- Сигнал pre_delete освобождал резервы ДО удаления
- Если удаление падало с ошибкой → резервы освобождены, Order не удалён
- Возникало расхождение данных
Решение #2: transaction.on_commit для освобождения резервов
- Добавлен @transaction.atomic к сигналу release_stock_on_order_delete
- Резервы получаются ДО удаления через list()
- Освобождение происходит через transaction.on_commit()
- Резервы освобождаются ТОЛЬКО если удаление успешно
- Гарантия целостности данных
Изменённые файлы:
- myproject/inventory/signals.py - оба сигнала исправлены
- RESERVATION_FIX.md - полная документация
Проблема:
- При изменении статуса заказа на 'Выполнен' товар списывался дважды
- Заказ на 10 шт создавал Sale на 10 шт, но со склада уходило 20 шт
Найдено ДВЕ причины:
1. Повторное обновление резервов через .save() (inventory/signals.py)
- Резервы обновлялись через res.save() каждый раз при сохранении заказа
- Это вызывало сигнал update_stock_on_reservation_change
- При повторном сохранении заказа происходило двойное срабатывание
Решение:
- Проверка дубликатов ПЕРЕД обновлением резервов
- Замена .save() на .update() для массового обновления без вызова сигналов
- Ручное обновление Stock после .update()
2. Двойное FIFO-списание (inventory/services/sale_processor.py)
- Sale создавалась с processed=False
- Сигнал process_sale_fifo срабатывал и списывал товар (1-й раз)
- Затем SaleProcessor.create_sale() тоже списывал товар (2-й раз)
Решение:
- Sale создаётся сразу с processed=True
- Сигнал не срабатывает, списание только в сервисе
Дополнительно:
- Ограничен выбор статусов при создании заказа только промежуточными
- Статус 'Черновик' установлен по умолчанию
- Убран пустой выбор '-------' из поля статуса
Изменённые файлы:
- myproject/orders/forms.py - настройки статусов для формы заказа
- myproject/inventory/signals.py - исправление сигнала create_sale_on_order_completion
- myproject/inventory/services/sale_processor.py - исправление create_sale
- myproject/test_order_status_default.py - обновлён тест
- DOUBLE_SALE_FIX.md - документация по исправлению
Проблема: Резервы не обновлялись в статус 'converted_to_sale' если Sale уже существовали,
что приводило к отображению завершенных заказов на странице активных резервов.
Решение: Переместил обновление резервов ПЕРЕД проверкой существования Sale.
Теперь резервы всегда обновляются при переходе заказа в статус 'completed',
независимо от того, были ли уже созданы записи Sale.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Проблема: При создании заказа instance.status может быть None,
что вызывало AttributeError при попытке доступа к .code
Решение: Добавлена проверка 'not instance.status' перед
обращением к instance.status.code
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Проблема: Сигнал post_save срабатывает несколько раз,
создавая дубликаты Sale для одного заказа.
Решения:
1. Добавлена проверка Sale.objects.filter(order=instance).exists()
перед созданием продаж (inventory/signals.py:74-75)
2. Создана management команда fix_duplicate_sales для исправления
существующих дубликатов
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Проблема: Order.status - это объект OrderStatus, а не строка.
Сравнение instance.status != 'completed' всегда возвращало True.
Решение: Сравниваем instance.status.code != 'completed'
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Изменения:
- Сигнал create_sale_on_order_shipment переименован в create_sale_on_order_completion
- Списание товаров (создание Sale) теперь происходит при статусе 'completed' вместо 'in_delivery'
- Исправлен выбор склада: используется Order.pickup_warehouse, если задан
- Та же логика применена к резервированию товаров
Обоснование для цветочного бизнеса:
- Букет может вернуться в магазин (клиента нет дома, перенос доставки)
- Товар физически находится в магазине до момента доставки
- Резерв показывает что товар занят - этого достаточно для промежуточных статусов
- Простота: списываем только когда ТОЧНО продали
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Проблема:
- calculate_total() пытался автоматически обрабатывать переплату
- Это приводило к дублированию логики и сложной отладке
- Нарушался принцип единственной ответственности
Решение:
- Удалена автоматическая обработка переплаты из Order.calculate_total()
- Теперь calculate_total() ТОЛЬКО считает сумму - всё
- Переплата обрабатывается ТОЛЬКО в TransactionService при создании платежей/возвратов
- Добавлено предупреждение в UI о переплате с инструкцией
Как работает теперь:
1. При оплате - TransactionService автоматически вызывает add_overpayment()
2. При изменении товаров - calculate_total() только пересчитывает сумму
3. Если появилась переплата - оператор видит предупреждение
4. Оператор вручную создаёт возврат в кошелёк через форму
Преимущества:
- Одно место ответственности за переплаты
- Прозрачность для оператора
- Нет скрытых автоматизмов
- Легко обслуживать и отлаживать
- Стандартный подход для e-commerce
Проблема:
- add_overpayment вызывался дважды:
1. При оплате 300 руб (заказ 150) → +150 в кошелёк
2. При изменении суммы до 100 → +200 в кошелёк
- Итого: 350 руб вместо правильных 200 руб
Причина:
- calculate_total() вызывал add_overpayment при любой переплате
- Не учитывалось, что переплата уже была обработана при оплате
Решение:
- Сохраняем old_total перед пересчётом
- Вызываем add_overpayment ТОЛЬКО если:
- old_total > 0 (заказ уже существовал)
- total_amount < old_total (сумма УМЕНЬШИЛАСЬ)
- amount_paid > total_amount (есть переплата)
- Это предотвращает двойную обработку при первичной оплате
Теперь переплата обрабатывается корректно только при изменении суммы заказа
Проблема:
- Создан заказ на 150 руб, оплачено 300 руб → 150 руб в кошелёк
- Изменены товары, сумма стала 100 руб
- amount_paid остался 300, total_amount стал 100
- Новая переплата 200 руб НЕ переносилась в кошелёк автоматически
Решение:
- В Order.calculate_total() добавлена проверка переплаты после пересчёта суммы
- Если amount_paid > total_amount, вызывается WalletService.add_overpayment()
- Излишек автоматически переносится в кошелёк, amount_paid нормализуется до total_amount
- Создаётся WalletTransaction для аудита
Теперь при уменьшении суммы заказа переплата корректно возвращается клиенту
- Устранён N+1 для статусов заказов: добавлен select_related('status')
- Расчёт total_debt перенесён на сторону БД через aggregate с Greatest/Coalesce
- Избежана загрузка всех активных заказов в память для подсчёта долга
- Количество активных заказов теперь считается через count() без загрузки данных
- Ожидаемый эффект: минус 10+ запросов на страницу, быстрее рендер при большом количестве заказов
- Исправлен баг отображения статуса: теперь сравнение с order.status.code вместо order.status
- Добавлена обработка отсутствующего статуса (показывает 'Без статуса')
- Пагинация истории заказов: добавлен якорь #ordersHistoryCollapse ко всем ссылкам
- Автооткрытие collapse при переходе по пагинации через JavaScript
- Плавная прокрутка к секции истории после раскрытия collapse (событие shown.bs.collapse)
- Пользователь остаётся в секции истории заказов при переходе между страницами
- История кошелька и история заказов теперь под collapse (свёрнуты по умолчанию)
- Кликабельные заголовки с иконкой chevron для раскрытия
- Badge с количеством элементов в заголовке
- Кнопка 'Новый заказ' доступна в свёрнутом состоянии (event.stopPropagation)
- Компактный и удобный интерфейс для работы с большими списками
- Пополнение и Списание размещены рядом (col-md-6 каждая)
- Упрощены тексты заголовков, лейблов и кнопок для компактности
- Фиксированная высота для подсказок — обеспечена симметрия полей
- Убран разделитель между формами
- Короткие placeholder'ы в полях ввода
- Информация о клиенте — левая колонка (col-md-6)
- Операции с кошельком — правая колонка (col-md-6)
- Баланс кошелька перенесён из таблицы в заголовок блока операций
- История кошелька и история заказов остаются в полную ширину
- Компактный вертикальный layout форм в правой колонке
- Добавлены view wallet_deposit и wallet_withdraw с защитой (login_required, is_staff, CSRF)
- Новые маршруты: /customers/<pk>/wallet/deposit/ и /customers/<pk>/wallet/withdraw/
- UI на странице клиента: две симметричные формы для пополнения и списания баланса
- Пополнение: произвольная сумма с обязательным описанием (подарки, компенсации)
- Возврат/списание: с ограничением макс. суммы = текущий баланс, обязательное описание
- Все операции логируются в WalletTransaction с типом 'adjustment'
- Защита от операций с системным клиентом
- Компактный симметричный дизайн форм с фиксированной высотой подсказок