Удалена вся документация .md из репозитория
- Удалены все файлы .md (30 файлов) - Добавлена маска *.md в .gitignore для защиты от будущих коммитов - Причина: .md файлы содержали примеры паролей и внутреннюю документацию 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,99 +0,0 @@
|
|||||||
# Claude Notes - Test Qwen Project
|
|
||||||
|
|
||||||
## Важные команды для этого проекта
|
|
||||||
|
|
||||||
### Django Management Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверка Django проекта (БЕЗ ошибок с путями!)
|
|
||||||
cd /c/Users/team_/Desktop/test_qwen/myproject && ../venv/Scripts/python.exe manage.py check
|
|
||||||
|
|
||||||
# Запуск сервера разработки
|
|
||||||
cd /c/Users/team_/Desktop/test_qwen/myproject && ../venv/Scripts/python.exe manage.py runserver
|
|
||||||
|
|
||||||
# Создание миграций
|
|
||||||
cd /c/Users/team_/Desktop/test_qwen/myproject && ../venv/Scripts/python.exe manage.py makemigrations
|
|
||||||
|
|
||||||
# Применение миграций
|
|
||||||
cd /c/Users/team_/Desktop/test_qwen/myproject && ../venv/Scripts/python.exe manage.py migrate
|
|
||||||
|
|
||||||
# Создание суперпользователя
|
|
||||||
cd /c/Users/team_/Desktop/test_qwen/myproject && ../venv/Scripts/python.exe manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
- **Корень проекта:** `c:\Users\team_\Desktop\test_qwen\`
|
|
||||||
- **Django проект:** `c:\Users\team_\Desktop\test_qwen\myproject\`
|
|
||||||
- **Virtual environment:** `c:\Users\team_\Desktop\test_qwen\venv\`
|
|
||||||
|
|
||||||
## Особенности
|
|
||||||
|
|
||||||
- Проект работает на Windows
|
|
||||||
- Используется Git Bash, поэтому пути в Unix-стиле: `/c/Users/...`
|
|
||||||
- Python из venv: `../venv/Scripts/python.exe` (относительно myproject/)
|
|
||||||
|
|
||||||
## Недавние изменения
|
|
||||||
|
|
||||||
### 2025-10-22: Система хранения и отображения изображений v1.0 ✅
|
|
||||||
|
|
||||||
**Frontend интеграция:**
|
|
||||||
- `all_products_list.html` - миниатюры (150x150, 438B)
|
|
||||||
- `product_detail.html` - сетка миниатюр + модальное окно с большим (800x800, 5.6K)
|
|
||||||
- `productkit_detail.html` - средний размер в сайдбаре (400x400, 2.9K) + модальное окно с большим
|
|
||||||
- `category_detail.html` - средний размер (400x400, 2.9K)
|
|
||||||
- Все списки используют миниатюры для быстрой загрузки
|
|
||||||
|
|
||||||
**Примеры использования в шаблонах:**
|
|
||||||
```django
|
|
||||||
{{ photo.get_thumbnail_url }} # для списков (150x150, 438B)
|
|
||||||
{{ photo.get_medium_url }} # для карточек (400x400, 2.9K)
|
|
||||||
{{ photo.get_large_url }} # для галерей (800x800, 5.6K)
|
|
||||||
{{ photo.get_original_url }} # для оригинала (full quality)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Результаты:**
|
|
||||||
- 93% экономия трафика для миниатюр
|
|
||||||
- 12× быстрее загрузка списков товаров
|
|
||||||
- Полная автоматизация создания размеров
|
|
||||||
|
|
||||||
**Документация:**
|
|
||||||
- `FRONTEND_IMAGES_GUIDE.md` - полное руководство для фронтенда
|
|
||||||
|
|
||||||
### 2025-10-22: Система хранения изображений v1.0 (Backend) ✅
|
|
||||||
Полностью реализована и протестирована система автоматической обработки изображений:
|
|
||||||
|
|
||||||
**Что создано:**
|
|
||||||
- `products/utils/image_processor.py` - обработка и создание размеров
|
|
||||||
- `products/utils/image_service.py` - получение URL нужного размера
|
|
||||||
- Обновлены модели: ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
|
||||||
- Management команда: `python manage.py process_images`
|
|
||||||
- Админка с превью всех 4 версий изображения
|
|
||||||
|
|
||||||
**Особенности:**
|
|
||||||
- 4 автоматических размера: thumbnail (150x150), medium (400x400), large (800x800), original
|
|
||||||
- Структурированное хранилище: media/products/originals/, media/products/thumbnails/, и т.д.
|
|
||||||
- Методы в моделях: `photo.get_thumbnail_url()`, `photo.get_medium_url()`, и т.д.
|
|
||||||
- 90% экономия размера для миниатюр
|
|
||||||
|
|
||||||
**API в шаблонах:**
|
|
||||||
```django
|
|
||||||
{{ photo.get_thumbnail_url }} # для списков (150x150, 438B)
|
|
||||||
{{ photo.get_medium_url }} # для карточек (400x400, 2.9K)
|
|
||||||
{{ photo.get_large_url }} # для просмотра (800x800, 5.6K)
|
|
||||||
{{ photo.get_original_url }} # оригинал (full quality, 6.1K)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Документация:**
|
|
||||||
- `IMAGE_STORAGE_STRATEGY.md` - полная документация
|
|
||||||
- `QUICK_START_IMAGES.md` - быстрый старт
|
|
||||||
- `IMAGE_SYSTEM_EXAMPLES.md` - примеры кода
|
|
||||||
|
|
||||||
### 2025-10-22: Переделка навигации
|
|
||||||
- Обновлена шапка с 4 ссылками: Товары, Заказы, Клиенты, Касса
|
|
||||||
- Создан объединённый view `CombinedProductListView` для товаров и комплектов
|
|
||||||
- Добавлен компонент быстрых фильтров по категориям
|
|
||||||
- URL структура:
|
|
||||||
- `/` → все товары и комплекты
|
|
||||||
- `/products/` → только товары поштучно
|
|
||||||
- `/kits/` → только комплекты
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -69,3 +69,6 @@ IMPLEMENTATION_SUMMARY.md
|
|||||||
FINAL_REPORT.md
|
FINAL_REPORT.md
|
||||||
start_celery.bat
|
start_celery.bat
|
||||||
start_celery.sh
|
start_celery.sh
|
||||||
|
|
||||||
|
# All markdown files
|
||||||
|
*.md
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
# Card-Based Attribute Interface - Completion Report
|
|
||||||
|
|
||||||
## Status: ✅ COMPLETE
|
|
||||||
|
|
||||||
Успешно реализован карточный интерфейс для управления атрибутами вариативных товаров (ConfigurableKitProduct).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Что было сделано
|
|
||||||
|
|
||||||
### 1. ✅ Обновлена Форма ([products/forms.py](myproject/products/forms.py))
|
|
||||||
|
|
||||||
**ConfigurableKitProductAttributeForm**:
|
|
||||||
- Убрано поле `option` (теперь добавляется через JavaScript)
|
|
||||||
- Оставлены поля: `name`, `position`, `visible`
|
|
||||||
- Добавлены CSS классы для JavaScript селекторов
|
|
||||||
|
|
||||||
**BaseConfigurableKitProductAttributeFormSet**:
|
|
||||||
- Обновлена валидация для карточной структуры
|
|
||||||
- Проверка на дубликаты параметров (каждый параметр один раз)
|
|
||||||
- Выявление пустых карточек
|
|
||||||
|
|
||||||
**Формсеты**:
|
|
||||||
- `ConfigurableKitProductAttributeFormSetCreate`: поля = `['name', 'position', 'visible']`
|
|
||||||
- `ConfigurableKitProductAttributeFormSetUpdate`: поля = `['name', 'position', 'visible']`
|
|
||||||
|
|
||||||
### 2. ✅ Переделан Шаблон ([products/templates/products/configurablekit_form.html](myproject/products/templates/products/configurablekit_form.html))
|
|
||||||
|
|
||||||
**Новая структура**:
|
|
||||||
```
|
|
||||||
┌─ Параметр: Длина ────────────────┐
|
|
||||||
│ Позиция: 0 │
|
|
||||||
│ Видимый: ✓ │
|
|
||||||
│ ────────────────────────────────│
|
|
||||||
│ Значения: │
|
|
||||||
│ [50] ✕ [60] ✕ [70] ✕ │
|
|
||||||
│ [+ Добавить значение] │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Компоненты**:
|
|
||||||
- Карточка для каждого параметра (`.attribute-card`)
|
|
||||||
- Поля параметра вверху карточки
|
|
||||||
- Контейнер значений с инлайн инпутами (`.value-fields-wrapper`)
|
|
||||||
- Кнопка "Добавить значение" для инлайн добавления
|
|
||||||
- Кнопка "Добавить параметр" для создания новых карточек
|
|
||||||
- Удаление через чекбокс DELETE
|
|
||||||
|
|
||||||
### 3. ✅ Добавлен JavaScript ([configurablekit_form.html lines 464-646](myproject/products/templates/products/configurablekit_form.html#L464-L646))
|
|
||||||
|
|
||||||
**Основные функции**:
|
|
||||||
|
|
||||||
1. **addValueField(container, valueText)**
|
|
||||||
- Добавляет новое поле значения в контейнер
|
|
||||||
- Генерирует уникальный ID для каждого значения
|
|
||||||
- Добавляет кнопку удаления
|
|
||||||
|
|
||||||
2. **initializeParameterCards()**
|
|
||||||
- Инициализирует все карточки при загрузке
|
|
||||||
- Подключает обработчики событий
|
|
||||||
|
|
||||||
3. **initAddValueBtn(card)**
|
|
||||||
- Инициализирует кнопку "Добавить значение" для карточки
|
|
||||||
- Вызывает addValueField при клике
|
|
||||||
|
|
||||||
4. **addParameterBtn listener**
|
|
||||||
- Создает новую карточку параметра с правильными индексами
|
|
||||||
- Инициализирует новую карточку
|
|
||||||
- Обновляет TOTAL_FORMS счетчик
|
|
||||||
|
|
||||||
5. **initParamDeleteToggle(card)**
|
|
||||||
- Скрывает карточку при отметке DELETE
|
|
||||||
- Восстанавливает при снятии отметки
|
|
||||||
|
|
||||||
6. **serializeAttributeValues()**
|
|
||||||
- Читает все значения из инлайн инпутов (`.parameter-value-input`)
|
|
||||||
- Создает JSON массив значений для каждого параметра
|
|
||||||
- Сохраняет в скрытые поля: `attributes-X-values`
|
|
||||||
|
|
||||||
7. **Form submission handler**
|
|
||||||
- Перед отправкой вызывает `serializeAttributeValues()`
|
|
||||||
- Гарантирует что все значения отправляются в POST
|
|
||||||
|
|
||||||
### 4. ✅ Обновлены Views ([products/views/configurablekit_views.py](myproject/products/views/configurablekit_views.py))
|
|
||||||
|
|
||||||
**ConfigurableKitProductCreateView**:
|
|
||||||
- Добавлен метод `_save_attributes_from_cards()`
|
|
||||||
- В `form_valid()` вызывает `_save_attributes_from_cards()` вместо сохранения formset
|
|
||||||
|
|
||||||
**ConfigurableKitProductUpdateView**:
|
|
||||||
- Добавлен метод `_save_attributes_from_cards()` (копия)
|
|
||||||
- В `form_valid()` вызывает `_save_attributes_from_cards()` вместо сохранения formset
|
|
||||||
|
|
||||||
**Логика сохранения**:
|
|
||||||
```python
|
|
||||||
def _save_attributes_from_cards(self):
|
|
||||||
# 1. Удаляем все старые атрибуты
|
|
||||||
# 2. Итерируем по количеству карточек (attributes-TOTAL_FORMS)
|
|
||||||
# 3. Для каждой карточки:
|
|
||||||
# - Читаем: name, position, visible, DELETE
|
|
||||||
# - Читаем JSON значения из attributes-X-values
|
|
||||||
# - Пропускаем если помечена для удаления
|
|
||||||
# - Создаем ConfigurableKitProductAttribute для каждого значения
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Новый Интерфейс
|
|
||||||
|
|
||||||
### До (Строки):
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Название | Значение | Позиция | ❌ │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ Длина | 50 | 0 | ❌ │
|
|
||||||
│ Длина | 60 | 0 | ❌ │
|
|
||||||
│ Длина | 70 | 0 | ❌ │
|
|
||||||
│ Упаковка | БЕЗ | 1 | ❌ │
|
|
||||||
│ Упаковка | В УП | 1 | ❌ │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
+ Добавить атрибут
|
|
||||||
```
|
|
||||||
|
|
||||||
### После (Карточки):
|
|
||||||
```
|
|
||||||
┌─ Длина ─────────────────────────────┐
|
|
||||||
│ Позиция: 0 │ Видимый: ✓ │ ❌ │
|
|
||||||
│─────────────────────────────────────│
|
|
||||||
│ Значения: [50] ✕ [60] ✕ [70] ✕ │
|
|
||||||
│ [+ Добавить значение] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
|
|
||||||
┌─ Упаковка ──────────────────────────┐
|
|
||||||
│ Позиция: 1 │ Видимый: ✓ │ ❌ │
|
|
||||||
│─────────────────────────────────────│
|
|
||||||
│ Значения: [БЕЗ] ✕ [В УП] ✕ │
|
|
||||||
│ [+ Добавить значение] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
|
|
||||||
[+ Добавить параметр]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Поток Данных
|
|
||||||
|
|
||||||
### Создание товара с атрибутами:
|
|
||||||
|
|
||||||
1. **Пользователь вводит**:
|
|
||||||
- Название товара
|
|
||||||
- Параметр 1: "Длина" → Значения: 50, 60, 70
|
|
||||||
- Параметр 2: "Упаковка" → Значения: БЕЗ, В УПАКОВКЕ
|
|
||||||
|
|
||||||
2. **JavaScript сериализует**:
|
|
||||||
```
|
|
||||||
attributes-0-name = "Длина"
|
|
||||||
attributes-0-position = "0"
|
|
||||||
attributes-0-visible = "on"
|
|
||||||
attributes-0-values = ["50", "60", "70"] ← JSON array!
|
|
||||||
|
|
||||||
attributes-1-name = "Упаковка"
|
|
||||||
attributes-1-position = "1"
|
|
||||||
attributes-1-visible = "on"
|
|
||||||
attributes-1-values = ["БЕЗ", "В УПАКОВКЕ"] ← JSON array!
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **View обрабатывает**:
|
|
||||||
```python
|
|
||||||
for idx in range(total_forms):
|
|
||||||
name = request.POST.get(f'attributes-{idx}-name')
|
|
||||||
values_json = request.POST.get(f'attributes-{idx}-values')
|
|
||||||
values = json.loads(values_json) # ["50", "60", "70"]
|
|
||||||
|
|
||||||
# Создает по одному объекту на каждое значение:
|
|
||||||
for value in values:
|
|
||||||
ConfigurableKitProductAttribute.create(
|
|
||||||
parent=product,
|
|
||||||
name=name,
|
|
||||||
option=value,
|
|
||||||
position=position,
|
|
||||||
visible=visible
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **В БД сохраняется**:
|
|
||||||
```
|
|
||||||
ConfigurableKitProduct: {name: "Товар", sku: "SKU"}
|
|
||||||
├── ConfigurableKitProductAttribute (Длина, 50)
|
|
||||||
├── ConfigurableKitProductAttribute (Длина, 60)
|
|
||||||
├── ConfigurableKitProductAttribute (Длина, 70)
|
|
||||||
├── ConfigurableKitProductAttribute (Упаковка, БЕЗ)
|
|
||||||
└── ConfigurableKitProductAttribute (Упаковка, В УПАКОВКЕ)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Преимущества Новой Архитектуры
|
|
||||||
|
|
||||||
### Для пользователя:
|
|
||||||
- ✅ Один раз вводит название параметра (не в каждой строке)
|
|
||||||
- ✅ Быстрее добавлять значения (инлайн, без перезагрузки)
|
|
||||||
- ✅ Очищает интуитивнее (карточки вместо множества строк)
|
|
||||||
- ✅ Визуально разделены параметры и их значения
|
|
||||||
- ✅ Легче управлять большим количеством параметров
|
|
||||||
|
|
||||||
### Для разработчика:
|
|
||||||
- ✅ Чистая структура данных в БД (не изменилась)
|
|
||||||
- ✅ Модели остаются той же (ConfigurableKitProductAttribute)
|
|
||||||
- ✅ Логика обработки четкая и понятная
|
|
||||||
- ✅ JSON сериализация безопасна (используется json.loads)
|
|
||||||
- ✅ Масштабируемо на сотни параметров
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Тестирование
|
|
||||||
|
|
||||||
### Проведено:
|
|
||||||
- ✅ test_card_interface.py - проверка структуры данных
|
|
||||||
- ✅ Python синтаксис проверен и валидирован
|
|
||||||
- ✅ JavaScript логика протестирована
|
|
||||||
|
|
||||||
### Результаты:
|
|
||||||
```
|
|
||||||
[1] Creating test product...
|
|
||||||
OK: Created product: Card Test Product
|
|
||||||
|
|
||||||
[2] Creating attributes (simulating card interface)...
|
|
||||||
OK: Created parameter 'Dlina' with 3 values: 50, 60, 70
|
|
||||||
OK: Created parameter 'Upakovka' with 2 values: BEZ, V_UPAKOVKE
|
|
||||||
|
|
||||||
[3] Verifying attribute structure...
|
|
||||||
OK: Found 2 unique parameters
|
|
||||||
OK: All assertions passed!
|
|
||||||
|
|
||||||
[4] Testing data retrieval...
|
|
||||||
OK: Retrieved attribute: Dlina = 50
|
|
||||||
OK: Can order by position and name
|
|
||||||
|
|
||||||
OK: CARD INTERFACE TEST PASSED!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 Измененные Файлы
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ myproject/products/forms.py
|
|
||||||
- ConfigurableKitProductAttributeForm (переделана)
|
|
||||||
- BaseConfigurableKitProductAttributeFormSet (обновлена)
|
|
||||||
- ConfigurableKitProductAttributeFormSetCreate/Update (поля обновлены)
|
|
||||||
|
|
||||||
✅ myproject/products/templates/products/configurablekit_form.html
|
|
||||||
- Секция атрибутов (строки → карточки)
|
|
||||||
- JavaScript (новые функции для управления)
|
|
||||||
|
|
||||||
✅ myproject/products/views/configurablekit_views.py
|
|
||||||
- ConfigurableKitProductCreateView._save_attributes_from_cards()
|
|
||||||
- ConfigurableKitProductUpdateView._save_attributes_from_cards()
|
|
||||||
- form_valid() обновлены в обеих Views
|
|
||||||
|
|
||||||
✅ Новый тест: myproject/test_card_interface.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Как Использовать
|
|
||||||
|
|
||||||
### Создание вариативного товара с новым интерфейсом:
|
|
||||||
|
|
||||||
1. Откройте `/products/configurable-kits/create/`
|
|
||||||
2. Заполните название товара
|
|
||||||
3. В секции "Параметры товара":
|
|
||||||
- Введите название параметра (например, "Длина")
|
|
||||||
- Установите позицию и видимость
|
|
||||||
- Нажимайте "Добавить значение" для каждого значения
|
|
||||||
- Повторите для других параметров
|
|
||||||
4. Создавайте варианты в секции ниже
|
|
||||||
5. Сохраните
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Известные Особенности
|
|
||||||
|
|
||||||
1. **JavaScript требует**: Используется ES6 (const, arrow functions)
|
|
||||||
2. **Браузерная совместимость**: IE11 не поддерживается (используется ES6)
|
|
||||||
3. **JSON сериализация**: Безопасна, используется встроенный JSON.stringify/parse
|
|
||||||
4. **Позиция параметра**: Одна для всех значений (правильно для группировки)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Статистика Изменений
|
|
||||||
|
|
||||||
```
|
|
||||||
Строк кода добавлено: ~500
|
|
||||||
Строк кода удалено: ~200
|
|
||||||
Сложность снижена: Да (формы упрощены)
|
|
||||||
Производительность: Не изменилась (БД запросы те же)
|
|
||||||
Тесты добавлены: 1 (test_card_interface.py)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Чек-лист
|
|
||||||
|
|
||||||
- [x] Форма переделана
|
|
||||||
- [x] Шаблон обновлен
|
|
||||||
- [x] JavaScript написан
|
|
||||||
- [x] Views обновлены
|
|
||||||
- [x] Сериализация реализована
|
|
||||||
- [x] Тесты написаны и пройдены
|
|
||||||
- [x] Синтаксис проверен
|
|
||||||
- [x] Коммит создан
|
|
||||||
- [x] Документация написана
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Итоговый Комментарий
|
|
||||||
|
|
||||||
Реализован полностью функциональный карточный интерфейс для управления атрибутами вариативных товаров.
|
|
||||||
|
|
||||||
**Ключевая особенность**: Пользователь вводит название параметра один раз, а затем добавляет столько значений, сколько нужно, через инлайн кнопки.
|
|
||||||
|
|
||||||
**Как это работает**:
|
|
||||||
1. JavaScript читает все значения из инлайн инпутов
|
|
||||||
2. Сохраняет их в JSON формате перед отправкой
|
|
||||||
3. View парсит JSON и создает отдельные объекты в БД
|
|
||||||
|
|
||||||
**БД структура не изменилась**, используется та же ConfigurableKitProductAttribute модель.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Date**: November 18, 2025
|
|
||||||
**Status**: Production Ready ✅
|
|
||||||
|
|
||||||
🤖 Generated with Claude Code
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# ConfigurableKitProduct Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Successfully implemented a complete variable product system for binding multiple ProductKits to attribute value combinations. The system allows creating variable products with attributes and dynamically selecting ProductKits for each variant.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. Database Models ([products/models/kits.py](myproject/products/models/kits.py))
|
|
||||||
|
|
||||||
#### ConfigurableKitOptionAttribute Model (NEW)
|
|
||||||
- **Purpose**: M2M relationship between ConfigurableKitOption variants and ConfigurableKitProductAttribute values
|
|
||||||
- **Fields**:
|
|
||||||
- `option`: ForeignKey to ConfigurableKitOption (with related_name='attributes_set')
|
|
||||||
- `attribute`: ForeignKey to ConfigurableKitProductAttribute
|
|
||||||
- **Constraints**:
|
|
||||||
- unique_together: ('option', 'attribute') - ensures one value per attribute per variant
|
|
||||||
- Indexed on both fields for query performance
|
|
||||||
|
|
||||||
#### ConfigurableKitOption Model (UPDATED)
|
|
||||||
- **Removed**: TextField for attributes (replaced with M2M)
|
|
||||||
- **Relationship**: New reverse relation `attributes_set` through ConfigurableKitOptionAttribute
|
|
||||||
|
|
||||||
### 2. Database Migrations ([products/migrations/0006_add_configurablekitoptionattribute.py](myproject/products/migrations/0006_add_configurablekitoptionattribute.py))
|
|
||||||
|
|
||||||
- Created migration for ConfigurableKitOptionAttribute model
|
|
||||||
- Applied successfully to database schema
|
|
||||||
|
|
||||||
### 3. Forms ([products/forms.py](myproject/products/forms.py))
|
|
||||||
|
|
||||||
#### ConfigurableKitOptionForm (REFACTORED)
|
|
||||||
- **Removed**: 'attributes' field from Meta.fields
|
|
||||||
- **Added**: Dynamic field generation in __init__ method
|
|
||||||
- Generates ModelChoiceField for each parent attribute
|
|
||||||
- Field names follow pattern: `attribute_{attribute_name}`
|
|
||||||
- For edit mode: pre-populates current attribute values
|
|
||||||
- **Example**: If parent has "Длина" and "Упаковка" attributes:
|
|
||||||
- Creates `attribute_Длина` field
|
|
||||||
- Creates `attribute_Упаковка` field
|
|
||||||
|
|
||||||
#### BaseConfigurableKitOptionFormSet (ENHANCED)
|
|
||||||
- **Added**: Comprehensive validation in clean() method
|
|
||||||
- Checks for duplicate kits
|
|
||||||
- Validates all attributes are filled for each variant
|
|
||||||
- Ensures max one default variant
|
|
||||||
- Provides detailed error messages per variant number
|
|
||||||
|
|
||||||
#### Formsets (UPDATED)
|
|
||||||
- ConfigurableKitOptionFormSetCreate: extra=1, fields=['kit', 'is_default']
|
|
||||||
- ConfigurableKitOptionFormSetUpdate: extra=0, fields=['kit', 'is_default']
|
|
||||||
|
|
||||||
### 4. Views ([products/views/configurablekit_views.py](myproject/products/views/configurablekit_views.py))
|
|
||||||
|
|
||||||
#### ConfigurableKitProductCreateView.form_valid() (UPDATED)
|
|
||||||
- Iterates through option_formset
|
|
||||||
- Saves ConfigurableKitOption with parent
|
|
||||||
- Creates ConfigurableKitOptionAttribute records for each selected attribute
|
|
||||||
- Uses transaction.atomic() for data consistency
|
|
||||||
|
|
||||||
#### ConfigurableKitProductUpdateView.form_valid() (UPDATED)
|
|
||||||
- Same logic as Create view
|
|
||||||
- Properly deletes old attribute relationships before creating new ones
|
|
||||||
|
|
||||||
### 5. Template ([products/templates/products/configurablekit_form.html](myproject/products/templates/products/configurablekit_form.html))
|
|
||||||
|
|
||||||
#### Form Structure (REORDERED)
|
|
||||||
- Attributes section now appears BEFORE variants
|
|
||||||
- Users define attributes first, then bind ProductKits to attribute combinations
|
|
||||||
|
|
||||||
#### Dynamic Attribute Display
|
|
||||||
- Variant form rows iterate through dynamically generated attribute fields
|
|
||||||
- Renders select dropdowns for each attribute field
|
|
||||||
- Field names follow pattern: `options-{formIdx}-attribute_{name}`
|
|
||||||
|
|
||||||
#### JavaScript Enhancement
|
|
||||||
- addOptionBtn listener dynamically generates attribute selects
|
|
||||||
- Clones structure from first form's attribute fields
|
|
||||||
- Properly names new fields with correct formset indices
|
|
||||||
|
|
||||||
### 6. Test Scripts (NEW)
|
|
||||||
|
|
||||||
#### test_configurable_simple.py
|
|
||||||
- Verifies models and relationships exist
|
|
||||||
- Checks form generation
|
|
||||||
- Validates view imports
|
|
||||||
|
|
||||||
#### test_workflow.py
|
|
||||||
- Complete end-to-end workflow test
|
|
||||||
- Creates ConfigurableKitProduct
|
|
||||||
- Creates attributes with multiple values
|
|
||||||
- Creates variants with M2M attribute bindings
|
|
||||||
- Verifies data retrieval
|
|
||||||
|
|
||||||
**Test Results**: All tests PASSED ✓
|
|
||||||
- Successfully created 3 variants with 2 attributes each
|
|
||||||
- All data retrieved correctly through M2M relationships
|
|
||||||
- Form validation logic intact
|
|
||||||
|
|
||||||
## Usage Workflow
|
|
||||||
|
|
||||||
### Step 1: Create Variable Product
|
|
||||||
1. Go to /products/configurable-kits/create/
|
|
||||||
2. Enter product name and SKU
|
|
||||||
3. Define attributes in the attributes section:
|
|
||||||
- Attribute Name: e.g., "Длина"
|
|
||||||
- Attribute Values: e.g., "50", "60", "70"
|
|
||||||
|
|
||||||
### Step 2: Create Variants
|
|
||||||
1. In variants section, for each variant:
|
|
||||||
- Select a ProductKit
|
|
||||||
- Select values for each attribute
|
|
||||||
- Mark as default (max 1)
|
|
||||||
2. Form validates:
|
|
||||||
- All attributes must be filled
|
|
||||||
- No duplicate kits
|
|
||||||
- Only one default variant
|
|
||||||
|
|
||||||
### Step 3: Save
|
|
||||||
- System creates:
|
|
||||||
- ConfigurableKitOption records
|
|
||||||
- ConfigurableKitOptionAttribute relationships
|
|
||||||
- All in atomic transaction
|
|
||||||
|
|
||||||
## Data Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
ConfigurableKitProduct (parent product)
|
|
||||||
├── parent_attributes (ConfigurableKitProductAttribute)
|
|
||||||
│ ├── name: "Длина", option: "50"
|
|
||||||
│ ├── name: "Длина", option: "60"
|
|
||||||
│ ├── name: "Упаковка", option: "БЕЗ"
|
|
||||||
│ └── name: "Упаковка", option: "В УПАКОВКЕ"
|
|
||||||
│
|
|
||||||
└── options (ConfigurableKitOption - variants)
|
|
||||||
├── Option 1: kit=Kit-1
|
|
||||||
│ └── attributes_set (ConfigurableKitOptionAttribute)
|
|
||||||
│ ├── attribute: Длина=50
|
|
||||||
│ └── attribute: Упаковка=БЕЗ
|
|
||||||
│
|
|
||||||
├── Option 2: kit=Kit-2
|
|
||||||
│ └── attributes_set
|
|
||||||
│ ├── attribute: Длина=60
|
|
||||||
│ └── attribute: Упаковка=В УПАКОВКЕ
|
|
||||||
│
|
|
||||||
└── Option 3: kit=Kit-3
|
|
||||||
└── attributes_set
|
|
||||||
├── attribute: Длина=70
|
|
||||||
└── attribute: Упаковка=БЕЗ
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
✓ **M2M Architecture**: Clean separation between attribute definitions and variant bindings
|
|
||||||
✓ **Validation**: Ensures all attributes present for each variant
|
|
||||||
✓ **Dynamic Forms**: Attribute fields generated based on parent configuration
|
|
||||||
✓ **Data Consistency**: Atomic transactions for multi-part operations
|
|
||||||
✓ **User-Friendly**: Attributes section appears before variants in form
|
|
||||||
✓ **Flexible**: Attributes can be reordered and positioned
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- All attributes are REQUIRED for each variant if defined on parent
|
|
||||||
- Maximum ONE value per attribute per variant (enforced by unique_together)
|
|
||||||
- Maximum ONE default variant per product (enforced by validation)
|
|
||||||
- No backward compatibility with old TextField attributes (intentional - fresh start)
|
|
||||||
- Supports any number of attributes and values
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the test scripts to verify implementation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd myproject
|
|
||||||
python test_configurable_simple.py # Basic model/form tests
|
|
||||||
python test_workflow.py # Full workflow test
|
|
||||||
```
|
|
||||||
|
|
||||||
Both tests should pass with "OK: ALL TESTS PASSED!" message.
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# Исправление двойного списания товаров при смене статуса заказа
|
|
||||||
|
|
||||||
## 🐛 Проблема
|
|
||||||
|
|
||||||
При смене статуса заказа на "Выполнен" (`completed`) происходило **двойное списание товара со склада**:
|
|
||||||
- В заказе было 10 штук товара
|
|
||||||
- Sale (продажа) регистрировалась на 10 штук
|
|
||||||
- Но со склада списывалось 20 штук
|
|
||||||
|
|
||||||
### Причины двойного списания
|
|
||||||
|
|
||||||
Было обнаружено **ДВА независимых источника** проблемы:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔥 Проблема #1: Повторное обновление резервов через `.save()`
|
|
||||||
|
|
||||||
### Файл: `inventory/signals.py` → сигнал `create_sale_on_order_completion`
|
|
||||||
|
|
||||||
**Старый код (ОШИБОЧНЫЙ):**
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender=Order)
|
|
||||||
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|
||||||
if created:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not instance.status or instance.status.code != 'completed':
|
|
||||||
return
|
|
||||||
|
|
||||||
# ❌ ПРОБЛЕМА: Резервы обновлялись ВСЕГДА через .save()
|
|
||||||
# Это вызывало сигнал update_stock_on_reservation_change каждый раз
|
|
||||||
for item in instance.items.all():
|
|
||||||
reservations = Reservation.objects.filter(
|
|
||||||
order_item=item,
|
|
||||||
status='reserved'
|
|
||||||
)
|
|
||||||
for res in reservations:
|
|
||||||
res.status = 'converted_to_sale'
|
|
||||||
res.converted_at = timezone.now()
|
|
||||||
res.save() # ← Вызывает сигнал, который пересчитывает Stock
|
|
||||||
|
|
||||||
# Проверка на дубликаты только ПОСЛЕ обновления резервов
|
|
||||||
if Sale.objects.filter(order=instance).exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Создание Sale...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Сценарий двойного срабатывания:**
|
|
||||||
1. Первое сохранение заказа со статусом `completed` → резервы обновляются → Sale создаётся
|
|
||||||
2. **Повторное сохранение** того же заказа (например, через админку) → резервы **снова** обновляются через `.save()` → вызывается сигнал `update_stock_on_reservation_change` → возможно некорректное двойное списание
|
|
||||||
|
|
||||||
### ✅ Решение #1: Использовать `.update()` вместо `.save()`
|
|
||||||
|
|
||||||
**Новый код (ИСПРАВЛЕННЫЙ):**
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender=Order)
|
|
||||||
def create_sale_on_order_completion(sender, instance, created, **kwargs):
|
|
||||||
if created:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not instance.status or instance.status.code != 'completed':
|
|
||||||
return
|
|
||||||
|
|
||||||
# ✅ СНАЧАЛА проверяем дубликаты
|
|
||||||
if Sale.objects.filter(order=instance).exists():
|
|
||||||
return # Продажи уже созданы, выходим БЕЗ обновления резервов
|
|
||||||
|
|
||||||
# ✅ Обновляем резервы ТОЛЬКО если Sale ещё не созданы
|
|
||||||
# ✅ Используем .update() вместо .save() чтобы избежать вызова сигналов
|
|
||||||
reservations_to_update = Reservation.objects.filter(
|
|
||||||
order_item__order=instance,
|
|
||||||
status='reserved'
|
|
||||||
)
|
|
||||||
|
|
||||||
if reservations_to_update.exists():
|
|
||||||
# Массовое обновление БЕЗ вызова сигналов
|
|
||||||
reservations_to_update.update(
|
|
||||||
status='converted_to_sale',
|
|
||||||
converted_at=timezone.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Обновляем Stock вручную, т.к. update() не вызывает сигналы
|
|
||||||
reservation_groups = reservations_to_update.values_list('product_id', 'warehouse_id').distinct()
|
|
||||||
|
|
||||||
for product_id, warehouse_id in reservation_groups:
|
|
||||||
try:
|
|
||||||
stock = Stock.objects.get(
|
|
||||||
product_id=product_id,
|
|
||||||
warehouse_id=warehouse_id
|
|
||||||
)
|
|
||||||
stock.refresh_from_batches()
|
|
||||||
except Stock.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Создание Sale...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ключевые изменения:**
|
|
||||||
1. ✅ Проверка на дубликаты **перед** обновлением резервов
|
|
||||||
2. ✅ Использование `.update()` вместо `.save()` → не вызывает сигналы
|
|
||||||
3. ✅ Ручное обновление Stock после массового обновления резервов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔥 Проблема #2: Двойное FIFO-списание в `SaleProcessor.create_sale()`
|
|
||||||
|
|
||||||
### Файл: `inventory/services/sale_processor.py` → метод `create_sale`
|
|
||||||
|
|
||||||
**Старый код (ОШИБОЧНЫЙ):**
|
|
||||||
```python
|
|
||||||
@transaction.atomic
|
|
||||||
def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None):
|
|
||||||
# ❌ ПРОБЛЕМА: Sale создаётся с processed=False
|
|
||||||
sale = Sale.objects.create(
|
|
||||||
product=product,
|
|
||||||
warehouse=warehouse,
|
|
||||||
quantity=quantity,
|
|
||||||
sale_price=sale_price,
|
|
||||||
order=order,
|
|
||||||
document_number=document_number,
|
|
||||||
processed=False # ← Сигнал process_sale_fifo сработает!
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# ❌ Списываем товар первый раз (в сервисе)
|
|
||||||
allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity)
|
|
||||||
|
|
||||||
for batch, qty_allocated in allocations:
|
|
||||||
SaleBatchAllocation.objects.create(...)
|
|
||||||
|
|
||||||
# Устанавливаем processed=True
|
|
||||||
sale.processed = True
|
|
||||||
sale.save(update_fields=['processed'])
|
|
||||||
|
|
||||||
return sale
|
|
||||||
except ValueError as e:
|
|
||||||
sale.delete()
|
|
||||||
raise
|
|
||||||
```
|
|
||||||
|
|
||||||
**Сценарий двойного списания:**
|
|
||||||
1. `Sale.objects.create(processed=False)` → **срабатывает сигнал `process_sale_fifo`**
|
|
||||||
2. Сигнал `process_sale_fifo` → списывает товар **первый раз** (10 шт)
|
|
||||||
3. `StockBatchManager.write_off_by_fifo()` в сервисе → списывает товар **второй раз** (10 шт)
|
|
||||||
4. **Итого: 20 шт списано вместо 10!**
|
|
||||||
|
|
||||||
### ✅ Решение #2: Создавать Sale сразу с `processed=True`
|
|
||||||
|
|
||||||
**Новый код (ИСПРАВЛЕННЫЙ):**
|
|
||||||
```python
|
|
||||||
@transaction.atomic
|
|
||||||
def create_sale(product, warehouse, quantity, sale_price, order=None, document_number=None):
|
|
||||||
# ✅ ВАЖНО: Устанавливаем processed=True сразу, чтобы сигнал process_sale_fifo не сработал
|
|
||||||
# (списание делаем вручную ниже, чтобы избежать двойного списания)
|
|
||||||
sale = Sale.objects.create(
|
|
||||||
product=product,
|
|
||||||
warehouse=warehouse,
|
|
||||||
quantity=quantity,
|
|
||||||
sale_price=sale_price,
|
|
||||||
order=order,
|
|
||||||
document_number=document_number,
|
|
||||||
processed=True # ✅ Сразу отмечаем как обработанную
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# ✅ Списываем товар ОДИН раз (сигнал НЕ сработает)
|
|
||||||
allocations = StockBatchManager.write_off_by_fifo(product, warehouse, quantity)
|
|
||||||
|
|
||||||
for batch, qty_allocated in allocations:
|
|
||||||
SaleBatchAllocation.objects.create(...)
|
|
||||||
|
|
||||||
# processed уже установлен в True при создании Sale
|
|
||||||
return sale
|
|
||||||
except ValueError as e:
|
|
||||||
sale.delete()
|
|
||||||
raise
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ключевые изменения:**
|
|
||||||
1. ✅ Sale создаётся сразу с `processed=True` → сигнал `process_sale_fifo` не срабатывает
|
|
||||||
2. ✅ Списание товара происходит **только один раз** в сервисе
|
|
||||||
3. ✅ Удалён дублирующий код `sale.processed = True; sale.save()`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Итоговые изменения
|
|
||||||
|
|
||||||
### Файл 1: `inventory/signals.py`
|
|
||||||
- Строки 85-103: Переработан сигнал `create_sale_on_order_completion`
|
|
||||||
- Проверка дубликатов перемещена наверх
|
|
||||||
- Замена `.save()` на `.update()` для резервов
|
|
||||||
- Добавлено ручное обновление Stock
|
|
||||||
|
|
||||||
### Файл 2: `inventory/services/sale_processor.py`
|
|
||||||
- Строки 87-96: Sale создаётся с `processed=True`
|
|
||||||
- Строки 111-113: Удалён дублирующий код установки `processed=True`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Результат
|
|
||||||
|
|
||||||
После исправления:
|
|
||||||
- ✅ Списание товара происходит **строго один раз**
|
|
||||||
- ✅ Нет повторного срабатывания сигналов при редактировании заказа
|
|
||||||
- ✅ Sale не создаются дважды
|
|
||||||
- ✅ Количество в Sale = количество списанное со склада
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Как проверить исправление
|
|
||||||
|
|
||||||
1. Создайте заказ с товаром (10 шт)
|
|
||||||
2. Измените статус на "Выполнен"
|
|
||||||
3. Проверьте:
|
|
||||||
- Sale создалась с quantity=10
|
|
||||||
- Со склада списалось ровно 10 шт (не 20!)
|
|
||||||
4. Повторно сохраните заказ (через админку или форму)
|
|
||||||
5. Проверьте:
|
|
||||||
- Sale НЕ создалась повторно
|
|
||||||
- Количество на складе не изменилось
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Lessons Learned
|
|
||||||
|
|
||||||
### Проблемы с Django Signals:
|
|
||||||
1. **Избегайте `.save()` в массовых операциях** → используйте `.update()`
|
|
||||||
2. **Проверяйте дубликаты ДО модификации данных**, а не после
|
|
||||||
3. **Флаг `processed` должен устанавливаться при создании**, если обработка делается вручную
|
|
||||||
4. **Signals могут срабатывать многократно** при редактировании через разные интерфейсы
|
|
||||||
|
|
||||||
### Best Practices:
|
|
||||||
- ✅ Используйте `queryset.update()` для массовых обновлений (не вызывает сигналы)
|
|
||||||
- ✅ Вручную обновляйте зависимые данные (Stock) после `.update()`
|
|
||||||
- ✅ Устанавливайте флаги обработки (`processed`) при создании объекта
|
|
||||||
- ✅ Проверяйте существование записей ДО их создания
|
|
||||||
- ✅ Используйте транзакции (`@transaction.atomic`) для критичных операций
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Дата исправления: 2024-12-01
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
# Исправление критического бага дублирования резервов
|
|
||||||
|
|
||||||
## 🔥 СЕРЬЁЗНЕЙШИЙ БАГ
|
|
||||||
|
|
||||||
### Описание проблемы
|
|
||||||
|
|
||||||
При изменении количества товара в заказе со статусом "Выполнен" (completed) создавался ДУБЛИКАТ резерва, что приводило к двойному списанию товара со склада.
|
|
||||||
|
|
||||||
### Сценарий воспроизведения
|
|
||||||
|
|
||||||
1. **Начальное состояние:**
|
|
||||||
- Заказ в статусе "Выполнен" (completed)
|
|
||||||
- OrderItem: quantity = 20 шт
|
|
||||||
- Reservation: quantity = 20 шт, status = `converted_to_sale`
|
|
||||||
- Sale: 20 шт создан
|
|
||||||
- Товар списан со склада
|
|
||||||
|
|
||||||
2. **Пользователь увеличивает количество на 10 единиц (до 30 шт):**
|
|
||||||
- OrderItem.quantity = 30 шт (было 20)
|
|
||||||
- Срабатывает сигнал `update_reservation_on_item_change`
|
|
||||||
|
|
||||||
3. **ЧТО ПРОИСХОДИЛО (БАГ):**
|
|
||||||
```python
|
|
||||||
# Старый код (ОШИБОЧНЫЙ):
|
|
||||||
reservation = Reservation.objects.filter(
|
|
||||||
order_item=instance,
|
|
||||||
status='reserved' # ← ПРОБЛЕМА!
|
|
||||||
).first()
|
|
||||||
```
|
|
||||||
- Сигнал искал резерв в статусе `'reserved'`
|
|
||||||
- НО резерв был в статусе `'converted_to_sale'` (уже продан!)
|
|
||||||
- Резерв НЕ находился → `reservation = None`
|
|
||||||
- **Создавался НОВЫЙ резерв на 30 шт!**
|
|
||||||
|
|
||||||
4. **Результат (ДУБЛИКАТ):**
|
|
||||||
- **Старый резерв:** 20 шт, status = `converted_to_sale` (остался!)
|
|
||||||
- **Новый резерв:** 30 шт, status = `reserved` (создан заново!)
|
|
||||||
- **ИТОГО: 50 шт зарезервировано вместо 30!**
|
|
||||||
|
|
||||||
5. **При переводе обратно в "Выполнен":**
|
|
||||||
- Создаётся Sale на 30 шт (дополнительно к существующим 20 шт)
|
|
||||||
- Оба резерва переводятся в `converted_to_sale`
|
|
||||||
- **На складе списывается 30 + 20 = 50 шт, хотя должно быть 30!**
|
|
||||||
- **Товара становится на 10 шт меньше, чем должно быть!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ РЕШЕНИЕ
|
|
||||||
|
|
||||||
### Изменения в коде
|
|
||||||
|
|
||||||
**Файл:** `inventory/signals.py`
|
|
||||||
**Функция:** `update_reservation_on_item_change` (строка 436)
|
|
||||||
|
|
||||||
#### Было (ОШИБОЧНО):
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender=OrderItem)
|
|
||||||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|
||||||
# Получаем резерв для этой позиции в статусе 'reserved'
|
|
||||||
reservation = Reservation.objects.filter(
|
|
||||||
order_item=instance,
|
|
||||||
status='reserved' # ← ПРОБЛЕМА: пропускает резервы в других статусах!
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if reservation:
|
|
||||||
# Обновляем количество
|
|
||||||
reservation.quantity = Decimal(str(instance.quantity))
|
|
||||||
reservation.save()
|
|
||||||
else:
|
|
||||||
# Создаем новый резерв (даже если старый уже существует!)
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Стало (ИСПРАВЛЕНО):
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender=OrderItem)
|
|
||||||
@transaction.atomic # ← Добавлена транзакция для безопасности
|
|
||||||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|
||||||
"""
|
|
||||||
КРИТИЧНО: Убран фильтр status='reserved' чтобы избежать дубликатов!
|
|
||||||
Резерв ищется по order_item независимо от статуса.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Ищем резерв для этой позиции в ЛЮБОМ статусе
|
|
||||||
reservation = Reservation.objects.filter(
|
|
||||||
order_item=instance # ← Без фильтра по статусу!
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if reservation:
|
|
||||||
# Резерв существует - обновляем ТОЛЬКО количество
|
|
||||||
# НЕ меняем статус! (может быть 'converted_to_sale', 'reserved', 'released')
|
|
||||||
old_quantity = reservation.quantity
|
|
||||||
reservation.quantity = Decimal(str(instance.quantity))
|
|
||||||
reservation.save(update_fields=['quantity']) # ← Обновляем только quantity
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"✓ Резерв #{reservation.id} обновлён: quantity {old_quantity} → {reservation.quantity} "
|
|
||||||
f"(статус: {reservation.status}, OrderItem #{instance.id}, заказ {instance.order.order_number})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Создаём новый резерв ТОЛЬКО если НЕТ вообще
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ключевые изменения
|
|
||||||
|
|
||||||
1. ✅ **Убран фильтр `status='reserved'`** - теперь ищем резерв в ЛЮБОМ статусе
|
|
||||||
2. ✅ **Добавлен `@transaction.atomic`** - обеспечивает атомарность операции
|
|
||||||
3. ✅ **Используем `save(update_fields=['quantity'])`** - обновляем только количество, статус НЕ меняется
|
|
||||||
4. ✅ **Добавлено логирование** - видим что происходит с резервами
|
|
||||||
5. ✅ **Обновлена документация** - объяснена критичность изменения
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 БЕЗОПАСНОСТЬ РЕШЕНИЯ
|
|
||||||
|
|
||||||
### Вопрос: "А если товар есть в разных заказах?"
|
|
||||||
|
|
||||||
**Ответ: НЕТ, проблемы не будет!**
|
|
||||||
|
|
||||||
### Почему безопасно:
|
|
||||||
|
|
||||||
**Один товар в разных заказах = разные OrderItem:**
|
|
||||||
|
|
||||||
1. **Заказ #1:**
|
|
||||||
- OrderItem #100: товар "Роза", количество 20 шт
|
|
||||||
- Reservation #1: `order_item=100`, product="Роза", quantity=20
|
|
||||||
|
|
||||||
2. **Заказ #2:**
|
|
||||||
- OrderItem #101: товар "Роза", количество 30 шт
|
|
||||||
- Reservation #2: `order_item=101`, product="Роза", quantity=30
|
|
||||||
|
|
||||||
### Ключ поиска:
|
|
||||||
|
|
||||||
```python
|
|
||||||
reservation = Reservation.objects.filter(
|
|
||||||
order_item=instance # ← Фильтруем по КОНКРЕТНОМУ OrderItem!
|
|
||||||
).first()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Фильтруем по `order_item=instance`, а НЕ по `product`!**
|
|
||||||
|
|
||||||
Это гарантирует:
|
|
||||||
- ✅ Для OrderItem #100 найдётся только Reservation #1
|
|
||||||
- ✅ Для OrderItem #101 найдётся только Reservation #2
|
|
||||||
- ✅ **Резервы НИКОГДА не пересекутся**, даже если товар одинаковый!
|
|
||||||
- ✅ Каждый OrderItem имеет СВОЙ уникальный резерв
|
|
||||||
- ✅ Резервы разных заказов НЕ конфликтуют (разные order_item)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 ПОКРЫТИЕ ВСЕХ СЦЕНАРИЕВ
|
|
||||||
|
|
||||||
| Сценарий | OrderItem | Резерв существует? | Статус резерва | Действие |
|
|
||||||
|----------|-----------|-------------------|----------------|----------|
|
|
||||||
| **Создание заказа** | Новый | Нет | - | Создать резерв (status='reserved') ✅ |
|
|
||||||
| **Добавление товара** | Новый | Нет | - | Создать резерв (status='reserved') ✅ |
|
|
||||||
| **Изменение qty (черновик)** | Старый | Да | reserved | Обновить quantity ✅ |
|
|
||||||
| **Изменение qty (выполнен)** | Старый | Да | converted_to_sale | Обновить quantity (БЕЗ смены статуса!) ✅ |
|
|
||||||
| **Повторное сохранение** | Старый | Да | Любой | Обновить quantity ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 ТЕСТИРОВАНИЕ
|
|
||||||
|
|
||||||
### Как проверить исправление:
|
|
||||||
|
|
||||||
1. **Создать заказ:**
|
|
||||||
- Добавить товар (20 шт)
|
|
||||||
- Перевести в статус "Выполнен"
|
|
||||||
- Проверить: создался Sale на 20 шт, резерв в статусе `converted_to_sale`
|
|
||||||
|
|
||||||
2. **Изменить количество:**
|
|
||||||
- Увеличить до 30 шт
|
|
||||||
- Проверить резервы в debug странице `/inventory/debug/`
|
|
||||||
- **Должен быть ОДИН резерв на 30 шт**, НЕ два!
|
|
||||||
|
|
||||||
3. **Проверить склад:**
|
|
||||||
- Товара должно остаться на складе правильное количество
|
|
||||||
- НЕ должно быть двойного списания
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 КОММИТ
|
|
||||||
|
|
||||||
```
|
|
||||||
Исправлен критический баг дублирования резервов при изменении количества
|
|
||||||
|
|
||||||
Проблема:
|
|
||||||
- При изменении количества в OrderItem для заказа в статусе 'completed'
|
|
||||||
- Создавался ДУБЛИКАТ резерва (старый + новый)
|
|
||||||
- Это приводило к двойному списанию товара со склада
|
|
||||||
- Фильтр status='reserved' пропускал резервы в статусе 'converted_to_sale'
|
|
||||||
|
|
||||||
Решение:
|
|
||||||
- Убран фильтр status='reserved' из поиска резерва
|
|
||||||
- Теперь резерв ищется по order_item независимо от статуса
|
|
||||||
- Обновляется только quantity, статус НЕ меняется
|
|
||||||
- Добавлен @transaction.atomic для атомарности
|
|
||||||
- Добавлено логирование операций с резервами
|
|
||||||
- Используется save(update_fields=['quantity']) для оптимизации
|
|
||||||
|
|
||||||
Безопасность:
|
|
||||||
- Резервы разных заказов НЕ конфликтуют (разные order_item)
|
|
||||||
- Один товар в разных заказах = разные OrderItem = разные Reservation
|
|
||||||
- Дубликаты больше НЕ создаются
|
|
||||||
|
|
||||||
Покрытие:
|
|
||||||
- Создание заказа ✅
|
|
||||||
- Добавление товара ✅
|
|
||||||
- Изменение количества (черновик) ✅
|
|
||||||
- Изменение количества (выполнен) ✅ [ИСПРАВЛЕНО]
|
|
||||||
- Повторное сохранение ✅
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ ВАЖНО
|
|
||||||
|
|
||||||
Это **критическое исправление**, которое влияет на:
|
|
||||||
- ✅ Учёт товара на складе
|
|
||||||
- ✅ Резервирование товаров
|
|
||||||
- ✅ FIFO-списание
|
|
||||||
- ✅ Продажи
|
|
||||||
|
|
||||||
**Необходимо протестировать** перед продакшеном!
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# Итоговый отчет об улучшениях системы ценообразования комплектов
|
|
||||||
|
|
||||||
## Дата: 2025-11-02
|
|
||||||
## Статус: ✅ Полностью готово к использованию
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Исправления, выполненные в этой сессии
|
|
||||||
|
|
||||||
### 1. Расчёт цены первого товара ✅
|
|
||||||
|
|
||||||
**Проблема:** Первая строка не считалась в цену. Цена начинала считаться только со второго товара.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
- Улучшена функция `getProductPrice()` с более строгой валидацией
|
|
||||||
- Улучшена функция `calculateFinalPrice()` с проверками:
|
|
||||||
- Пропуск пустых товаров
|
|
||||||
- Валидация количества (минимум 1)
|
|
||||||
- Проверка что цена > 0
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `productkit_create.html`
|
|
||||||
- `productkit_edit.html`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Отображение цены в Select2 ✅
|
|
||||||
|
|
||||||
**Проблема:** Select2 dropdown отображал обычную цену без скидки, а не `actual_price` (цену со скидкой).
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
- Обновлена функция `formatSelectResult()` в Select2 инициализации
|
|
||||||
- Теперь приоритет: `actual_price` (если есть скидка) → `price` (обычная цена)
|
|
||||||
|
|
||||||
**Файл:** `products/templates/products/includes/select2-product-init.html`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Количество по умолчанию ✅
|
|
||||||
|
|
||||||
**Проблема:** При добавлении первого товара поле количества было пустым. При добавлении второго товара появлялась 1 по умолчанию.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
- Добавлен метод `__init__` в класс `KitItemForm`
|
|
||||||
- Устанавливает `quantity.initial = 1` для новых форм
|
|
||||||
|
|
||||||
**Файл:** `products/forms.py`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Auto-select текста в поле количества ✅
|
|
||||||
|
|
||||||
**Проблема:** При клике на поле количества нужно было вручную выделять число перед его изменением.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
- Добавлен обработчик события `focus` для полей количества
|
|
||||||
- При клике поле автоматически выделяет весь текст
|
|
||||||
- Пользователь может сразу начать вводить новое значение с клавиатуры
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `productkit_create.html` (строки 657-659)
|
|
||||||
- `productkit_edit.html` (строки 657-659)
|
|
||||||
|
|
||||||
**Код:**
|
|
||||||
```javascript
|
|
||||||
quantityInput.addEventListener('focus', function() {
|
|
||||||
this.select();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Архитектура решения
|
|
||||||
|
|
||||||
### Поток расчёта цены
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Пользователь выбирает товар в Select2
|
|
||||||
2. select2:select событие срабатывает
|
|
||||||
3. getProductPrice() получает цену товара:
|
|
||||||
- Сначала проверяет кэш
|
|
||||||
- Затем data-атрибуты
|
|
||||||
- Затем Select2 data
|
|
||||||
- В последнюю очередь AJAX к API
|
|
||||||
4. calculateFinalPrice() вызывается
|
|
||||||
5. Для каждого товара:
|
|
||||||
- Проверяется что товар выбран
|
|
||||||
- Получается количество (или 1)
|
|
||||||
- Ждёт await getProductPrice()
|
|
||||||
- Суммирует actual_price × quantity
|
|
||||||
6. Базовая цена обновляется
|
|
||||||
7. Определяется тип корректировки (какое поле заполнено)
|
|
||||||
8. Рассчитывается финальная цена
|
|
||||||
9. Обновляются display элементы
|
|
||||||
```
|
|
||||||
|
|
||||||
### Валидация данных
|
|
||||||
|
|
||||||
**В Python (forms.py):**
|
|
||||||
- KitItemForm.clean() проверяет что quantity > 0
|
|
||||||
- ProductKitForm.clean() проверяет что adjustment_value > 0 если тип не 'none'
|
|
||||||
|
|
||||||
**В JavaScript:**
|
|
||||||
- getProductPrice() проверяет isNaN и productId > 0
|
|
||||||
- calculateFinalPrice() проверяет что товар выбран
|
|
||||||
- Валидация количества: если quantity <= 0, использует 1
|
|
||||||
|
|
||||||
### Пользовательский опыт
|
|
||||||
|
|
||||||
1. **При создании комплекта:**
|
|
||||||
- Первое поле количества уже имеет значение 1 ✓
|
|
||||||
- При выборе товара цена обновляется в реальном времени ✓
|
|
||||||
- Select2 показывает actual_price (цену со скидкой) ✓
|
|
||||||
- Клик на количество выделяет текст для быстрого ввода ✓
|
|
||||||
|
|
||||||
2. **При добавлении товара:**
|
|
||||||
- Новый товар имеет количество 1 по умолчанию ✓
|
|
||||||
- Обработчик auto-select работает и для новых полей ✓
|
|
||||||
|
|
||||||
3. **При редактировании:**
|
|
||||||
- Все сохранённые значения загружаются ✓
|
|
||||||
- Цена пересчитывается при изменении компонентов ✓
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Все изменённые файлы
|
|
||||||
|
|
||||||
| Файл | Изменение | Строки |
|
|
||||||
|------|-----------|---------|
|
|
||||||
| `products/forms.py` | Добавлен `__init__` в KitItemForm с `quantity.initial = 1` | 181-185 |
|
|
||||||
| `products/templates/includes/select2-product-init.html` | Обновлена formatSelectResult для отображения actual_price | 8-19 |
|
|
||||||
| `products/templates/productkit_create.html` | Добавлен обработчик auto-select для quantity | 657-659 |
|
|
||||||
| `products/templates/productkit_edit.html` | Добавлен обработчик auto-select для quantity | 657-659 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
### Сценарий 1: Первый товар ✓
|
|
||||||
```
|
|
||||||
1. Открыть http://grach.localhost:8000/products/kits/create/
|
|
||||||
2. Добавить товар "Роза красная"
|
|
||||||
3. ✓ Поле количества показывает 1
|
|
||||||
4. ✓ Базовая цена обновляется на 20.00
|
|
||||||
5. ✓ При клике на количество текст выделяется
|
|
||||||
6. Изменить на 3
|
|
||||||
7. ✓ Базовая цена обновляется на 60.00
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 2: Добавление второго товара ✓
|
|
||||||
```
|
|
||||||
1. Нажать "Добавить товар"
|
|
||||||
2. ✓ Новое поле имеет количество 1
|
|
||||||
3. Выбрать "Белая роза"
|
|
||||||
4. ✓ Цена обновляется (базовая = 60 + 5 = 65)
|
|
||||||
5. ✓ Auto-select работает для обоих полей
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 3: Select2 отображение ✓
|
|
||||||
```
|
|
||||||
1. В поле товара начать писать "роз"
|
|
||||||
2. ✓ Dropdown показывает товары с actual_price:
|
|
||||||
- "Роза красная" - 20.00 руб (со скидкой)
|
|
||||||
- Не 50.00 руб (обычная цена)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 4: Редактирование ✓
|
|
||||||
```
|
|
||||||
1. Создать комплект
|
|
||||||
2. Открыть для редактирования
|
|
||||||
3. ✓ Все значения загружены
|
|
||||||
4. ✓ Цена правильно отображается
|
|
||||||
5. ✓ Auto-select работает при клике
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Готово к запуску! 🎉
|
|
||||||
|
|
||||||
Все улучшения реализованы и готовы к использованию.
|
|
||||||
|
|
||||||
**Точки входа для тестирования:**
|
|
||||||
- Создание: http://grach.localhost:8000/products/kits/create/
|
|
||||||
- Редактирование: http://grach.localhost:8000/products/kits/
|
|
||||||
- API: http://grach.localhost:8000/products/api/search-products-variants/
|
|
||||||
|
|
||||||
**Новые возможности:**
|
|
||||||
✅ Расчёт цены для первого товара
|
|
||||||
✅ Правильное отображение actual_price в Select2
|
|
||||||
✅ Количество по умолчанию = 1
|
|
||||||
✅ Auto-select текста при клике на количество
|
|
||||||
✅ Логирование для отладки в консоли браузера
|
|
||||||
✅ Надёжная валидация данных на разных уровнях
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
# Kit Binding for ConfigurableKitProduct Attributes - Implementation Complete
|
|
||||||
|
|
||||||
## Status: ✅ COMPLETE AND TESTED
|
|
||||||
|
|
||||||
All tasks for implementing ProductKit binding to ConfigurableKitProductAttribute values have been successfully completed and verified.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 What Was Done
|
|
||||||
|
|
||||||
### 1. ✅ Model Update
|
|
||||||
**File**: [products/models/kits.py](myproject/products/models/kits.py) - Lines 406-462
|
|
||||||
|
|
||||||
Added ForeignKey field to `ConfigurableKitProductAttribute`:
|
|
||||||
```python
|
|
||||||
kit = models.ForeignKey(
|
|
||||||
ProductKit,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='as_attribute_value_in',
|
|
||||||
verbose_name="Комплект для этого значения",
|
|
||||||
help_text="Какой ProductKit связан с этим значением атрибута",
|
|
||||||
blank=True,
|
|
||||||
null=True
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- CASCADE delete (if kit is deleted, attributes are removed)
|
|
||||||
- Optional (NULL allowed for backward compatibility)
|
|
||||||
- Indexed field for efficient queries
|
|
||||||
- Updated unique_together constraint to include kit
|
|
||||||
|
|
||||||
### 2. ✅ Database Migration
|
|
||||||
**File**: [products/migrations/0007_add_kit_to_attribute.py](myproject/products/migrations/0007_add_kit_to_attribute.py)
|
|
||||||
|
|
||||||
- Auto-generated and applied successfully
|
|
||||||
- Handles existing data (NULL values for all current attributes)
|
|
||||||
- Creates proper indexes
|
|
||||||
|
|
||||||
### 3. ✅ Form Update
|
|
||||||
**File**: [products/forms.py](myproject/products/forms.py)
|
|
||||||
|
|
||||||
`ConfigurableKitProductAttributeForm`:
|
|
||||||
- Kit field is handled via JavaScript (not in form directly)
|
|
||||||
- Form serializes kit selections via JSON hidden fields
|
|
||||||
|
|
||||||
### 4. ✅ Template Enhancement
|
|
||||||
**File**: [products/templates/products/configurablekit_form.html](myproject/products/templates/products/configurablekit_form.html)
|
|
||||||
|
|
||||||
**Key Changes**:
|
|
||||||
- Injected available ProductKits into JavaScript via script tag
|
|
||||||
- Added kit selector dropdown in `addValueField()` function
|
|
||||||
- Each value now has associated kit selection
|
|
||||||
- JavaScript validates that kit is selected for each value
|
|
||||||
|
|
||||||
**Example HTML Structure**:
|
|
||||||
```html
|
|
||||||
<window.AVAILABLE_KITS = [
|
|
||||||
{ id: 1, name: "Kit A" },
|
|
||||||
{ id: 2, name: "Kit B" },
|
|
||||||
{ id: 3, name: "Kit C" }
|
|
||||||
]>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. ✅ JavaScript Update
|
|
||||||
**File**: [products/templates/products/configurablekit_form.html](myproject/products/templates/products/configurablekit_form.html) - Lines 466-676
|
|
||||||
|
|
||||||
**Updated Functions**:
|
|
||||||
|
|
||||||
1. **addValueField(container, valueText, kitId)**
|
|
||||||
- Now accepts optional kitId parameter
|
|
||||||
- Creates select dropdown populated from window.AVAILABLE_KITS
|
|
||||||
- Includes delete button for removal
|
|
||||||
|
|
||||||
2. **serializeAttributeValues()**
|
|
||||||
- Reads both value inputs AND kit selections
|
|
||||||
- Creates two JSON arrays: values and kits
|
|
||||||
- Stores in hidden fields: attributes-X-values and attributes-X-kits
|
|
||||||
- Only includes pairs where BOTH value and kit are filled
|
|
||||||
|
|
||||||
3. **Validation**
|
|
||||||
- Kit selection is required when value is entered
|
|
||||||
- Empty values/kits are filtered out before submission
|
|
||||||
|
|
||||||
### 6. ✅ View Implementation
|
|
||||||
**Files**:
|
|
||||||
- [products/views/configurablekit_views.py](myproject/products/views/configurablekit_views.py) - Lines 215-298 (CreateView)
|
|
||||||
- [products/views/configurablekit_views.py](myproject/products/views/configurablekit_views.py) - Lines 423-506 (UpdateView)
|
|
||||||
|
|
||||||
**ConfigurableKitProductCreateView._save_attributes_from_cards()**:
|
|
||||||
- Reads attributes-X-values JSON array
|
|
||||||
- Reads attributes-X-kits JSON array
|
|
||||||
- For each value, retrieves corresponding kit ID
|
|
||||||
- Looks up ProductKit object and creates ConfigurableKitProductAttribute with FK populated
|
|
||||||
- Gracefully handles missing kits (creates without kit if not found)
|
|
||||||
|
|
||||||
**ConfigurableKitProductUpdateView._save_attributes_from_cards()**:
|
|
||||||
- Identical implementation for consistency
|
|
||||||
|
|
||||||
**Data Flow**:
|
|
||||||
```python
|
|
||||||
# POST data example:
|
|
||||||
attributes-0-name = "Длина"
|
|
||||||
attributes-0-values = ["50", "60", "70"]
|
|
||||||
attributes-0-kits = [1, 2, 3]
|
|
||||||
|
|
||||||
# View processes:
|
|
||||||
for idx, value in enumerate(values):
|
|
||||||
kit_id = kits[idx] # 1, 2, 3
|
|
||||||
kit = ProductKit.objects.get(id=kit_id)
|
|
||||||
ConfigurableKitProductAttribute.objects.create(
|
|
||||||
parent=product,
|
|
||||||
name=name,
|
|
||||||
option=value,
|
|
||||||
kit=kit, # NEW!
|
|
||||||
position=position,
|
|
||||||
visible=visible
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. ✅ Testing
|
|
||||||
**File**: [test_kit_binding.py](myproject/test_kit_binding.py)
|
|
||||||
|
|
||||||
Complete test script verifying:
|
|
||||||
- ✅ ProductKit creation and retrieval
|
|
||||||
- ✅ Attribute creation with kit FK binding
|
|
||||||
- ✅ Mixed kit-bound and unbound attributes
|
|
||||||
- ✅ Querying attributes by kit
|
|
||||||
- ✅ Reverse queries (get kit for attribute value)
|
|
||||||
- ✅ FK relationship integrity
|
|
||||||
|
|
||||||
**Test Results**:
|
|
||||||
```
|
|
||||||
[OK] Total attributes: 5
|
|
||||||
[OK] Dlina values: 3 (each bound to different kit)
|
|
||||||
[OK] Upakovka values: 2 (one bound, one unbound)
|
|
||||||
[OK] Kit-bound attributes: 4
|
|
||||||
[OK] Unbound attributes: 1
|
|
||||||
|
|
||||||
Querying:
|
|
||||||
- Test Kit A: 7 attributes
|
|
||||||
- Test Kit B: 3 attributes
|
|
||||||
- Test Kit C: 3 attributes
|
|
||||||
- NULL kit: 3 attributes
|
|
||||||
|
|
||||||
Reverse Query: Value '60' -> Test Kit B
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 User Workflow
|
|
||||||
|
|
||||||
### How It Works in the UI
|
|
||||||
|
|
||||||
**Scenario**: Creating a "Длина" (Length) parameter with values bound to different kits
|
|
||||||
|
|
||||||
1. User enters parameter name: **Длина**
|
|
||||||
2. For first value:
|
|
||||||
- Enters: **50**
|
|
||||||
- Selects from dropdown: **Test Kit A**
|
|
||||||
- [+] Button adds value
|
|
||||||
3. For second value:
|
|
||||||
- Enters: **60**
|
|
||||||
- Selects from dropdown: **Test Kit B**
|
|
||||||
- [+] Button adds value
|
|
||||||
4. For third value:
|
|
||||||
- Enters: **70**
|
|
||||||
- Selects from dropdown: **Test Kit C**
|
|
||||||
- [+] Button adds value
|
|
||||||
|
|
||||||
**Form Submission**:
|
|
||||||
- JavaScript collects all values: ["50", "60", "70"]
|
|
||||||
- JavaScript collects all kit IDs: [1, 2, 3]
|
|
||||||
- Creates JSON: attributes-0-values and attributes-0-kits
|
|
||||||
- Sends to server
|
|
||||||
|
|
||||||
**Server Processing**:
|
|
||||||
- Parses JSON arrays
|
|
||||||
- Creates 3 ConfigurableKitProductAttribute records:
|
|
||||||
- Длина=50 → Kit A
|
|
||||||
- Длина=60 → Kit B
|
|
||||||
- Длина=70 → Kit C
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Database Structure
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- After migration:
|
|
||||||
configurablekitproductattribute
|
|
||||||
├── id (PK)
|
|
||||||
├── parent_id (FK to ConfigurableKitProduct)
|
|
||||||
├── name (CharField) -- "Длина"
|
|
||||||
├── option (CharField) -- "50", "60", "70"
|
|
||||||
├── position (IntegerField)
|
|
||||||
├── visible (BooleanField)
|
|
||||||
├── kit_id (FK to ProductKit) -- NEW!
|
|
||||||
└── Constraints:
|
|
||||||
unique_together = (('parent', 'name', 'option', 'kit'))
|
|
||||||
index on kit_id
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Query Examples
|
|
||||||
|
|
||||||
**Get all attributes with a specific kit**:
|
|
||||||
```python
|
|
||||||
kit = ProductKit.objects.get(id=1)
|
|
||||||
attrs = ConfigurableKitProductAttribute.objects.filter(kit=kit)
|
|
||||||
# Result: [Dlina=50, Upakovka=BEZ] (both bound to Kit A)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Get kit for specific attribute value**:
|
|
||||||
```python
|
|
||||||
attr = ConfigurableKitProductAttribute.objects.get(option="60")
|
|
||||||
kit = attr.kit # Test Kit B
|
|
||||||
```
|
|
||||||
|
|
||||||
**Get all unbound attributes** (no kit):
|
|
||||||
```python
|
|
||||||
unbound = ConfigurableKitProductAttribute.objects.filter(kit__isnull=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Get attributes grouped by kit**:
|
|
||||||
```python
|
|
||||||
from django.db.models import Count
|
|
||||||
attrs_by_kit = ConfigurableKitProductAttribute.objects.values('kit').annotate(count=Count('id'))
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Technical Details
|
|
||||||
|
|
||||||
### What Changed
|
|
||||||
|
|
||||||
| Component | Change | Impact |
|
|
||||||
|-----------|--------|--------|
|
|
||||||
| Model | Added kit FK | Attributes can now be linked to ProductKit |
|
|
||||||
| Migration | 0007_add_kit_to_attribute | Database schema updated, existing data unaffected |
|
|
||||||
| Form | JSON serialization for kits | Kit selections passed via hidden fields |
|
|
||||||
| Template | Kit selector UI | Users can choose kit for each value |
|
|
||||||
| JavaScript | Dual JSON arrays | values and kits arrays serialized in parallel |
|
|
||||||
| Views | Updated _save_attributes_from_cards() | Reads kit IDs and creates FK relationship |
|
|
||||||
|
|
||||||
### What Stayed the Same
|
|
||||||
|
|
||||||
✅ ConfigurableKitProductAttribute model structure (new field added, not replaced)
|
|
||||||
✅ Database query patterns (backward compatible)
|
|
||||||
✅ Admin interface (no changes needed)
|
|
||||||
✅ API serialization (works as-is with new field)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing Summary
|
|
||||||
|
|
||||||
**Automated Test**: `test_kit_binding.py`
|
|
||||||
- **Status**: ✅ PASSED
|
|
||||||
- **Coverage**:
|
|
||||||
- Model FK creation
|
|
||||||
- JSON serialization/deserialization
|
|
||||||
- Query filtering by kit
|
|
||||||
- Reverse queries
|
|
||||||
- NULL kit support
|
|
||||||
|
|
||||||
**Manual Testing Ready**:
|
|
||||||
1. Go to `/products/configurable-kits/create/`
|
|
||||||
2. Create product with parameters and kit selections
|
|
||||||
3. Verify kit is saved in database
|
|
||||||
4. Edit product and verify kit selections are restored
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Example Data
|
|
||||||
|
|
||||||
```
|
|
||||||
ConfigurableKitProduct: "T-Shirt Bundle"
|
|
||||||
├── Attribute: Размер (Size)
|
|
||||||
│ ├── S → Kit: "Small Bundle" (kit_id=1)
|
|
||||||
│ ├── M → Kit: "Medium Bundle" (kit_id=2)
|
|
||||||
│ └── L → Kit: "Large Bundle" (kit_id=3)
|
|
||||||
│
|
|
||||||
├── Attribute: Цвет (Color)
|
|
||||||
│ ├── Красный (Red) → Kit: "Red Collection" (kit_id=4)
|
|
||||||
│ ├── Синий (Blue) → Kit: "Blue Collection" (kit_id=5)
|
|
||||||
│ └── Зелёный (Green) → NULL (no kit)
|
|
||||||
│
|
|
||||||
└── Variants created from above combinations...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Next Steps (Optional)
|
|
||||||
|
|
||||||
1. **Variant Auto-Generation**: Auto-create variants based on attribute combinations
|
|
||||||
2. **Variant Pricing**: Add price adjustments per variant based on kit
|
|
||||||
3. **Stock Tracking**: Track inventory per variant
|
|
||||||
4. **Export**: WooCommerce export using kit information
|
|
||||||
5. **Validation Rules**: Add business rules for kit-attribute combinations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist
|
|
||||||
|
|
||||||
- [x] Model updated with kit FK
|
|
||||||
- [x] Migration created and applied
|
|
||||||
- [x] Form updated for kit handling
|
|
||||||
- [x] Template updated with kit UI
|
|
||||||
- [x] JavaScript serialization implemented
|
|
||||||
- [x] Views updated to save kit bindings
|
|
||||||
- [x] Tests created and passing
|
|
||||||
- [x] Backward compatibility maintained
|
|
||||||
- [x] Documentation complete
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Summary
|
|
||||||
|
|
||||||
**Kit binding for ConfigurableKitProduct attributes is now fully functional!**
|
|
||||||
|
|
||||||
Each attribute value can now be associated with a specific ProductKit, enabling:
|
|
||||||
- Multi-kit variants with different attribute bindings
|
|
||||||
- Complex product configurations
|
|
||||||
- Kit-specific pricing and inventory
|
|
||||||
- Clear separation of product variants
|
|
||||||
|
|
||||||
The implementation maintains backward compatibility (kit is optional/nullable) and follows Django best practices.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Date**: November 18, 2025
|
|
||||||
**Status**: Production Ready ✅
|
|
||||||
|
|
||||||
🤖 Generated with Claude Code
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Система динамического ценообразования комплектов - Готово к тестированию
|
|
||||||
|
|
||||||
## Резюме изменений
|
|
||||||
|
|
||||||
Реализована новая, упрощённая система ценообразования для комплектов (ProductKit), которая заменяет сложную систему с множественными методами.
|
|
||||||
|
|
||||||
### Архитектура решения
|
|
||||||
|
|
||||||
**Основной принцип:** Цена комплекта = сумма(actual_price компонентов × количество) + опциональная корректировка
|
|
||||||
|
|
||||||
### Компоненты системы
|
|
||||||
|
|
||||||
#### 1. **Модель ProductKit** (`products/models/kits.py`)
|
|
||||||
- **Новые поля:**
|
|
||||||
- `base_price` - сумма цен всех компонентов (пересчитывается автоматически)
|
|
||||||
- `price` - итоговая цена (база + корректировка)
|
|
||||||
- `price_adjustment_type` - тип корректировки (none, increase_percent, increase_amount, decrease_percent, decrease_amount)
|
|
||||||
- `price_adjustment_value` - значение корректировки (% или руб)
|
|
||||||
|
|
||||||
- **Ключевые методы:**
|
|
||||||
- `calculate_final_price()` - расчёт финальной цены с корректировкой
|
|
||||||
- `recalculate_base_price()` - пересчёт базовой цены из компонентов
|
|
||||||
|
|
||||||
#### 2. **Django Signal** (`inventory/signals.py`)
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender='products.Product')
|
|
||||||
def update_kit_prices_on_product_change(sender, instance, created, **kwargs):
|
|
||||||
"""Автоматически пересчитывает все комплекты при изменении цены товара"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. **API Endpoint** (`products/views/api_views.py`)
|
|
||||||
- Обновлён `search_products_and_variants()` для возврата `actual_price` в JSON
|
|
||||||
|
|
||||||
#### 4. **Форма ProductKit** (`products/forms.py`)
|
|
||||||
- Упрощена валидация
|
|
||||||
- Удалены старые поля ценообразования
|
|
||||||
- Оставлены только: name, sku, description, categories, tags, price_adjustment_type, price_adjustment_value
|
|
||||||
|
|
||||||
#### 5. **Шаблон создания комплекта** (`productkit_create.html`)
|
|
||||||
- **Удалены:**
|
|
||||||
- Выпадающий список для выбора типа корректировки
|
|
||||||
- **Добавлены:**
|
|
||||||
- 4 поля ввода в 2×2 сетке для автоматического определения типа:
|
|
||||||
- Увеличить на %
|
|
||||||
- Увеличить на руб
|
|
||||||
- Уменьшить на %
|
|
||||||
- Уменьшить на руб
|
|
||||||
- Real-time отображение базовой цены
|
|
||||||
- Real-time отображение финальной цены
|
|
||||||
|
|
||||||
#### 6. **Шаблон редактирования комплекта** (`productkit_edit.html`)
|
|
||||||
- Идентичен созданию
|
|
||||||
- Плюс автоматическая загрузка сохранённых значений корректировки
|
|
||||||
|
|
||||||
### JavaScript функциональность
|
|
||||||
|
|
||||||
#### Ключевые функции:
|
|
||||||
|
|
||||||
1. **getProductPrice(selectElement)** - async функция для получения цены товара
|
|
||||||
- Проверка кэша
|
|
||||||
- Проверка data-атрибутов
|
|
||||||
- Проверка Select2 data
|
|
||||||
- AJAX запрос к API при необходимости
|
|
||||||
|
|
||||||
2. **calculateFinalPrice()** - async функция для расчёта финальной цены
|
|
||||||
- Суммирует actual_price × quantity для всех компонентов
|
|
||||||
- Автоматически определяет тип корректировки (какое одно поле заполнено)
|
|
||||||
- Обновляет скрытые форм-поля (price_adjustment_type, price_adjustment_value)
|
|
||||||
- Обновляет display элементы в реальном времени
|
|
||||||
|
|
||||||
#### Event Handlers:
|
|
||||||
- Select2 события (select2:select, select2:unselect) → calculateFinalPrice()
|
|
||||||
- Input/change события в полях корректировки → calculateFinalPrice()
|
|
||||||
- Изменение количества → calculateFinalPrice()
|
|
||||||
|
|
||||||
### Данные в тенанте "grach"
|
|
||||||
|
|
||||||
Для тестирования доступны товары:
|
|
||||||
1. **Роза красная** - price: 50.00, sale: 20.00, actual: 20.00 ✓
|
|
||||||
2. **Белая роза** - price: 5.00, sale: null, actual: 5.00 ✓
|
|
||||||
3. **Ваниль гибискус** - price: 6.00, sale: null, actual: 6.00 ✓
|
|
||||||
4. **Хризантема оранжевая** - price: 5.00, sale: null, actual: 5.00 ✓
|
|
||||||
|
|
||||||
### Сценарии тестирования
|
|
||||||
|
|
||||||
#### Тест 1: Создание простого комплекта
|
|
||||||
```
|
|
||||||
1. Перейти на http://grach.localhost:8000/products/kits/create/
|
|
||||||
2. Заполнить название: "Букет из 3 роз"
|
|
||||||
3. Добавить товар "Роза красная" (qty: 3) → base_price должна быть 60.00 (20×3)
|
|
||||||
4. Увеличить на 10% → final_price должна быть 66.00 (60×1.10)
|
|
||||||
5. Сохранить и проверить
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Тест 2: Прямое увеличение суммой
|
|
||||||
```
|
|
||||||
1. Создать комплект с товарами на сумму 50 руб
|
|
||||||
2. В поле "Увеличить на руб" ввести 10
|
|
||||||
3. Final_price должна быть 60.00
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Тест 3: Уменьшение
|
|
||||||
```
|
|
||||||
1. Создать комплект базовой ценой 100 руб
|
|
||||||
2. Уменьшить на 20% → final_price = 80
|
|
||||||
3. Или уменьшить на 15 руб → final_price = 85
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Тест 4: Редактирование
|
|
||||||
```
|
|
||||||
1. Создать комплект с увеличением на 10%
|
|
||||||
2. Открыть для редактирования
|
|
||||||
3. Проверить, что значение 10 загружено в поле "Увеличить на %"
|
|
||||||
4. Изменить на 15% → final_price пересчитывается
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Тест 5: Автоматический пересчёт при изменении цены товара
|
|
||||||
```
|
|
||||||
1. Создать комплект с "Роза красная" (qty: 2), base_price = 40
|
|
||||||
2. В админке изменить sale_price розы на 15
|
|
||||||
3. Открыть комплект в БД или API → base_price должна пересчитаться на 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### Файлы изменены
|
|
||||||
|
|
||||||
| Файл | Изменение |
|
|
||||||
|------|-----------|
|
|
||||||
| `products/models/kits.py` | Полностью переписан с новой моделью ценообразования |
|
|
||||||
| `products/forms.py` | Упрощена, удалены старые поля |
|
|
||||||
| `products/views/api_views.py` | Добавлен actual_price в JSON responses |
|
|
||||||
| `products/views/productkit_views.py` | Обновлен контекст для actual_price |
|
|
||||||
| `products/templates/productkit_create.html` | Новый UI с 4 полями корректировки + real-time расчёты |
|
|
||||||
| `products/templates/productkit_edit.html` | Идентичен create + загрузка сохранённых значений |
|
|
||||||
| `products/templates/includes/kititem_formset.html` | Добавлены data-product-price атрибуты |
|
|
||||||
| `inventory/signals.py` | Добавлен обработчик для auto-recalculation при изменении Product |
|
|
||||||
| `products/migrations/0004_add_kit_price_adjustment_fields.py` | Migration для новых полей |
|
|
||||||
|
|
||||||
### Status
|
|
||||||
|
|
||||||
✅ **Миграция применена** - БД обновлена
|
|
||||||
✅ **API endpoint** - Возвращает actual_price
|
|
||||||
✅ **Шаблоны** - Полностью переработаны
|
|
||||||
✅ **JavaScript** - Реализована real-time калькуляция
|
|
||||||
✅ **Signal** - Готов автоматически пересчитывать при изменении товаров
|
|
||||||
✅ **Тестовые данные** - Есть товары в тенанте grach
|
|
||||||
|
|
||||||
### Готово к запуску
|
|
||||||
|
|
||||||
Система полностью готова к тестированию на http://grach.localhost:8000/products/kits/create/
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
# Решение: Изоляция фотографий товаров между тенантами
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
Фотографии товаров одного тенанта перезаписывали фотографии другого тенанта. Два разных тенанта с товарами ID=1 использовали одни и те же файлы:
|
|
||||||
```
|
|
||||||
media/products/1/1/original.jpg ← перезатиралось для каждого тенанта
|
|
||||||
```
|
|
||||||
|
|
||||||
## Решение: Tenant-Aware FileSystemStorage
|
|
||||||
|
|
||||||
Реализована полная изоляция файлов между тенантами через custom Django storage backend.
|
|
||||||
|
|
||||||
### Архитектура
|
|
||||||
|
|
||||||
**На диске (физическое хранилище):**
|
|
||||||
```
|
|
||||||
media/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
|
|
||||||
media/tenants/{tenant_id}/kits/{entity_id}/{photo_id}/{size}.ext
|
|
||||||
media/tenants/{tenant_id}/categories/{entity_id}/{photo_id}/{size}.ext
|
|
||||||
```
|
|
||||||
|
|
||||||
**В базе данных (для экономии и мобильности):**
|
|
||||||
```
|
|
||||||
products/{entity_id}/{photo_id}/{size}.ext
|
|
||||||
kits/{entity_id}/{photo_id}/{size}.ext
|
|
||||||
categories/{entity_id}/{photo_id}/{size}.ext
|
|
||||||
```
|
|
||||||
|
|
||||||
Tenant ID добавляется/удаляется автоматически при работе с файлами.
|
|
||||||
|
|
||||||
## Реализованные изменения
|
|
||||||
|
|
||||||
### 1. Создан Custom Storage Backend
|
|
||||||
|
|
||||||
**Файл:** `products/utils/storage.py`
|
|
||||||
|
|
||||||
Класс `TenantAwareFileSystemStorage` расширяет стандартный Django FileSystemStorage:
|
|
||||||
|
|
||||||
- `_get_tenant_id()` - Получает ID текущего тенанта из контекста django-tenants
|
|
||||||
- `_get_tenant_path()` - Добавляет tenant_id в начало пути
|
|
||||||
- `get_available_name()` - Проверяет уникальность на диске, но возвращает путь БЕЗ tenant_id для БД
|
|
||||||
- `_save()` - Сохраняет файл с tenant_id на диск, но возвращает путь БЕЗ tenant_id для БД
|
|
||||||
- `_open()` - Открывает файл, добавляя tenant_id если необходимо (критично для Celery!)
|
|
||||||
- `path()` - Преобразует относительные пути в полные системные пути с tenant_id
|
|
||||||
- `delete()` - Удаляет файлы с проверкой принадлежности тенанту (безопасность)
|
|
||||||
- `exists()` - Проверяет существование с валидацией тенанта
|
|
||||||
- `url()` - Генерирует URL с проверкой безопасности
|
|
||||||
|
|
||||||
**Безопасность:** Storage предотвращает доступ к файлам других тенантов и выбрасывает исключение при попытке кросс-тенантного доступа.
|
|
||||||
|
|
||||||
### 2. Обновлена конфигурация Django
|
|
||||||
|
|
||||||
**Файл:** `myproject/settings.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Обновлены модели фотографий
|
|
||||||
|
|
||||||
**Файл:** `products/models/photos.py`
|
|
||||||
|
|
||||||
- Заменены жесткие `upload_to='products/temp/'` на callable функции
|
|
||||||
- Функции генерируют пути БЕЗ tenant_id (добавляется автоматически storage)
|
|
||||||
- Добавлены комментарии о мультитенантности в docstring каждого класса
|
|
||||||
|
|
||||||
Функции upload_to:
|
|
||||||
- `get_product_photo_upload_path()` → `products/temp/{filename}`
|
|
||||||
- `get_kit_photo_upload_path()` → `kits/temp/{filename}`
|
|
||||||
- `get_category_photo_upload_path()` → `categories/temp/{filename}`
|
|
||||||
|
|
||||||
### 4. Обновлены утилиты обработки фотографий
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `products/utils/image_processor.py` - Добавлены комментарии о мультитенантности
|
|
||||||
- `products/utils/image_service.py` - Добавлены комментарии о структуре путей
|
|
||||||
- `products/tasks.py` - Обновлены комментарии о мультитенантности в Celery задачах
|
|
||||||
|
|
||||||
Важно: Эти файлы работают как есть благодаря архитектуре storage!
|
|
||||||
|
|
||||||
### 5. Созданы комплексные тесты
|
|
||||||
|
|
||||||
**Файл:** `products/tests/test_multi_tenant_photos.py`
|
|
||||||
|
|
||||||
Тесты проверяют:
|
|
||||||
- ✅ Что пути в БД не содержат tenant_id (для мобильности)
|
|
||||||
- ✅ Что пути на диске содержат tenant_id (для изоляции)
|
|
||||||
- ✅ Что фотографии разных тенантов сохраняются в разные места
|
|
||||||
- ✅ Что storage отказывает в доступе к файлам других тенантов
|
|
||||||
- ✅ Что storage настроен в settings
|
|
||||||
- ✅ Что качество фото устанавливается корректно
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск тестов
|
|
||||||
cd myproject
|
|
||||||
python manage.py test products.tests.test_multi_tenant_photos -v 2
|
|
||||||
```
|
|
||||||
|
|
||||||
**Результат:** Все 5 тестов проходят успешно ✅
|
|
||||||
|
|
||||||
## Как это работает
|
|
||||||
|
|
||||||
### Сценарий загрузки фото
|
|
||||||
|
|
||||||
1. **Пользователь загружает фото в tenant1**
|
|
||||||
- Django создает `ProductPhoto` объект
|
|
||||||
- Пользователь указывает файл (temporary)
|
|
||||||
|
|
||||||
2. **BasePhoto.save() срабатывает**
|
|
||||||
- Проверяет контекст (connection.schema_name = 'tenant_1')
|
|
||||||
- Запускает Celery задачу для асинхронной обработки
|
|
||||||
|
|
||||||
3. **ImageField сохраняет файл**
|
|
||||||
- Вызывает `TenantAwareFileSystemStorage._save()`
|
|
||||||
- Storage:
|
|
||||||
- Добавляет tenant_id: `tenants/tenant_1/products/temp/image.jpg`
|
|
||||||
- Сохраняет на диск: `media/tenants/tenant_1/products/temp/image.jpg`
|
|
||||||
- Возвращает БД путь БЕЗ tenant_id: `products/temp/image.jpg`
|
|
||||||
- Django сохраняет в БД: `products/temp/image.jpg`
|
|
||||||
|
|
||||||
4. **Celery обрабатывает фото в фоне**
|
|
||||||
- Активирует schema: `connection.set_schema('tenant_1')`
|
|
||||||
- Читает фото из БД (путь `products/temp/image.jpg`)
|
|
||||||
- Storage автоматически добавляет tenant_id при чтении
|
|
||||||
- Обрабатывает и создает размеры
|
|
||||||
- Сохраняет обработанные файлы
|
|
||||||
- Обновляет БД с путем: `products/{entity_id}/{photo_id}/original.jpg`
|
|
||||||
|
|
||||||
5. **Когда пользователь заходит в Tenant2**
|
|
||||||
- Товар с ID=1 в tenant2 имеет разные фото
|
|
||||||
- Файлы хранятся в: `media/tenants/tenant_2/products/1/{photo_id}/`
|
|
||||||
- Не пересекаются с tenant1!
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Защита от кросс-тенантного доступа
|
|
||||||
|
|
||||||
Storage проверяет tenant_id при операциях чтения/удаления:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def delete(self, name):
|
|
||||||
tenant_id = self._get_tenant_id()
|
|
||||||
if not name.startswith(f"tenants/{tenant_id}/"):
|
|
||||||
raise RuntimeError(f"Cannot delete file - belongs to different tenant")
|
|
||||||
```
|
|
||||||
|
|
||||||
Если пользователь попытается обратиться к файлу другого тенанта - получит исключение.
|
|
||||||
|
|
||||||
## Преимущества решения
|
|
||||||
|
|
||||||
✅ **Полная изоляция** - Файлы разных тенантов физически разделены
|
|
||||||
✅ **Безопасность** - Storage предотвращает кросс-тенантный доступ
|
|
||||||
✅ **Чистота БД** - Пути в БД не содержат tenant_id (более мобильно)
|
|
||||||
✅ **Минимум изменений** - ImageProcessor и ImageService работают без изменений
|
|
||||||
✅ **Асинхронность** - Celery полностью поддерживает мультитенантность
|
|
||||||
✅ **Масштабируемость** - Готово к переходу на S3 в будущем
|
|
||||||
✅ **Протестировано** - 5 комплексных тестов проходят успешно
|
|
||||||
|
|
||||||
## Путь к облаку (S3)
|
|
||||||
|
|
||||||
В будущем очень легко перейти на S3 хранилище:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Просто замените одну строку в settings.py:
|
|
||||||
|
|
||||||
# Текущая конфигурация
|
|
||||||
DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage'
|
|
||||||
|
|
||||||
# Облачное хранилище (S3)
|
|
||||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
|
||||||
|
|
||||||
# Структура путей остается той же!
|
|
||||||
# S3: s3://bucket/tenants/{tenant_id}/products/{entity_id}/{photo_id}/{size}.ext
|
|
||||||
```
|
|
||||||
|
|
||||||
Структура paths останется идентичной - нужны только зависимости и конфигурация подключения к S3.
|
|
||||||
|
|
||||||
## Отладка
|
|
||||||
|
|
||||||
### Логирование
|
|
||||||
|
|
||||||
Все операции логируются с префиксом `[Storage]` для удобства отладки:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Storage] Extracted tenant_id=tenant_1 from schema=tenant_1
|
|
||||||
[Storage] get_available_name: products/temp/image.jpg → checking disk with: tenants/tenant_1/products/temp/image.jpg
|
|
||||||
[Storage] Stripped tenant prefix: tenants/tenant_1/products/temp/image_lKjH.jpg → products/temp/image_lKjH.jpg
|
|
||||||
[Storage] _save: products/temp/image.jpg → tenants/tenant_1/products/temp/image.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка структуры файлов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# На диске файлы организованы так:
|
|
||||||
media/
|
|
||||||
├── tenants/
|
|
||||||
│ ├── tenant_1/
|
|
||||||
│ │ └── products/
|
|
||||||
│ │ └── temp/
|
|
||||||
│ │ └── image.jpg
|
|
||||||
│ └── tenant_2/
|
|
||||||
│ └── products/
|
|
||||||
│ └── temp/
|
|
||||||
│ └── image.jpg
|
|
||||||
```
|
|
||||||
|
|
||||||
## Миграция (если были старые фото)
|
|
||||||
|
|
||||||
Для проекта указано - начинаем с чистого листа, без миграции старых фото.
|
|
||||||
|
|
||||||
Если в будущем понадобится мигрировать старые данные:
|
|
||||||
1. Напишите management команду для перемещения файлов
|
|
||||||
2. Обновите пути в БД
|
|
||||||
3. Используйте storage для добавления tenant_id в пути
|
|
||||||
|
|
||||||
## Контрольный список
|
|
||||||
|
|
||||||
- ✅ Custom storage backend создан
|
|
||||||
- ✅ Settings обновлены
|
|
||||||
- ✅ Модели фотографий обновлены
|
|
||||||
- ✅ Комментарии добавлены во все утилиты
|
|
||||||
- ✅ Тесты написаны и проходят
|
|
||||||
- ✅ Безопасность валидирована
|
|
||||||
- ✅ Документация готова
|
|
||||||
|
|
||||||
## Следующие шаги
|
|
||||||
|
|
||||||
Когда проект вырастет:
|
|
||||||
|
|
||||||
1. **S3 миграция** - замените storage backend на S3
|
|
||||||
2. **CDN** - настройте CloudFront для ускорения доставки
|
|
||||||
3. **Бэкапы** - настройте S3 versioning и lifecycle policies
|
|
||||||
4. **Мониторинг** - добавьте метрики для отслеживания использования storage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Дата:** 2025-11-23
|
|
||||||
**Статус:** ✅ Готово к продакшену
|
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
# Система оценки качества фотографий товаров - Полное описание
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Реализована полнофункциональная система для оценки, отслеживания и визуализации качества фотографий товаров. Система полностью гибкая - все пороги и настройки читаются из `settings.py`, не требует редактирования кода при изменении параметров.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 1: Оценка качества и хранение данных
|
|
||||||
|
|
||||||
### Концепция
|
|
||||||
|
|
||||||
Система определяет качество фото на основе **процентного соотношения минимального размера фото к максимально возможному размеру** (устанавливается в settings).
|
|
||||||
|
|
||||||
**Формула расчета:**
|
|
||||||
```
|
|
||||||
quality_percent = min(width, height) / max_dimension (из settings)
|
|
||||||
|
|
||||||
Excellent: >= 95% (>= 2052px при max 2160px)
|
|
||||||
Good: >= 70% (>= 1512px)
|
|
||||||
Acceptable: >= 40% (>= 864px)
|
|
||||||
Poor: >= 20% (>= 432px)
|
|
||||||
Very Poor: < 20% (< 432px)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Конфигурация (settings.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
IMAGE_PROCESSING_CONFIG = {
|
|
||||||
'max_width': 2160,
|
|
||||||
'max_height': 2160,
|
|
||||||
'quality_threshold': 0.95, # Для excellent
|
|
||||||
# ... другие параметры
|
|
||||||
}
|
|
||||||
|
|
||||||
# Пороги качества (в процентах от max_dimension)
|
|
||||||
IMAGE_QUALITY_LEVELS = {
|
|
||||||
'excellent': 0.95, # >= 95%
|
|
||||||
'good': 0.70, # >= 70%
|
|
||||||
'acceptable': 0.40, # >= 40%
|
|
||||||
'poor': 0.20, # >= 20%
|
|
||||||
}
|
|
||||||
|
|
||||||
# Описания и визуальное оформление
|
|
||||||
IMAGE_QUALITY_LABELS = {
|
|
||||||
'excellent': {
|
|
||||||
'label': 'Отлично',
|
|
||||||
'color': 'success',
|
|
||||||
'icon': '✓',
|
|
||||||
'recommendation': 'Отличное качество, готово к выгрузке',
|
|
||||||
},
|
|
||||||
'good': {
|
|
||||||
'label': 'Хорошо',
|
|
||||||
'color': 'info',
|
|
||||||
'icon': '✓',
|
|
||||||
'recommendation': 'Хорошее качество, готово к выгрузке',
|
|
||||||
},
|
|
||||||
'acceptable': {
|
|
||||||
'label': 'Приемлемо',
|
|
||||||
'color': 'warning',
|
|
||||||
'icon': '⚠',
|
|
||||||
'recommendation': 'Приемлемое качество, рекомендуется обновить',
|
|
||||||
},
|
|
||||||
'poor': {
|
|
||||||
'label': 'Плохо',
|
|
||||||
'color': 'danger',
|
|
||||||
'icon': '✗',
|
|
||||||
'recommendation': 'Плохое качество, требует обновления',
|
|
||||||
},
|
|
||||||
'very_poor': {
|
|
||||||
'label': 'Очень плохо',
|
|
||||||
'color': 'danger',
|
|
||||||
'icon': '✗✗',
|
|
||||||
'recommendation': 'Очень плохое качество, обязательно обновить',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ключевое свойство:** Если вы измените `max_width` с 2160 на 2000, система **автоматически пересчитает** все пороги без изменения кода.
|
|
||||||
|
|
||||||
### Модели БД
|
|
||||||
|
|
||||||
#### ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
|
||||||
|
|
||||||
Добавлены два поля:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Уровень качества (excellent/good/acceptable/poor/very_poor)
|
|
||||||
quality_level = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=QUALITY_LEVEL_CHOICES,
|
|
||||||
default='acceptable',
|
|
||||||
db_index=True, # Для быстрой фильтрации
|
|
||||||
)
|
|
||||||
|
|
||||||
# Флаг требует ли обновления (poor или very_poor)
|
|
||||||
quality_warning = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
db_index=True, # Для быстрого поиска проблемных фото
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ImageProcessor
|
|
||||||
|
|
||||||
Обновлена функция `process_image()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def process_image(self, image_file, max_size=None, quality_level=75):
|
|
||||||
"""
|
|
||||||
Возвращает теперь:
|
|
||||||
{
|
|
||||||
'path': 'products/2024/photo.jpg',
|
|
||||||
'width': 2150,
|
|
||||||
'height': 2150,
|
|
||||||
'quality_level': 'excellent',
|
|
||||||
'quality_warning': False,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
Автоматически вычисляет качество при обработке фото.
|
|
||||||
|
|
||||||
### Валидаторы (products/validators/image_validators.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_max_dimension_from_config():
|
|
||||||
"""Читает max_width из settings динамически"""
|
|
||||||
max_width = getattr(settings, 'IMAGE_PROCESSING_CONFIG', {}).get('max_width', 2160)
|
|
||||||
return max_width
|
|
||||||
|
|
||||||
def get_image_quality_level(width, height):
|
|
||||||
"""Определяет уровень качества фото"""
|
|
||||||
min_dimension = min(width, height)
|
|
||||||
max_dimension = get_max_dimension_from_config()
|
|
||||||
quality_percent = min_dimension / max_dimension
|
|
||||||
|
|
||||||
quality_levels = getattr(settings, 'IMAGE_QUALITY_LEVELS', {...})
|
|
||||||
|
|
||||||
if quality_percent >= quality_levels.get('excellent', 0.95):
|
|
||||||
return 'excellent', False
|
|
||||||
# ... и т.д.
|
|
||||||
|
|
||||||
return 'very_poor', True # True означает quality_warning
|
|
||||||
|
|
||||||
def get_quality_info(quality_level):
|
|
||||||
"""Возвращает информацию о качестве из settings"""
|
|
||||||
return getattr(settings, 'IMAGE_QUALITY_LABELS', {}).get(quality_level, {})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Migration для БД
|
|
||||||
|
|
||||||
```
|
|
||||||
myproject/products/migrations/0003_productcategoryphoto_quality_level_and_more.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Добавляет поля `quality_level` и `quality_warning` ко всем трём моделям фото.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 2: Интерфейс админа
|
|
||||||
|
|
||||||
### QualityLevelFilter
|
|
||||||
|
|
||||||
Кастомный фильтр Django для отображения товаров по качеству фото:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class QualityLevelFilter(admin.SimpleListFilter):
|
|
||||||
title = 'Качество фото'
|
|
||||||
parameter_name = 'photo_quality'
|
|
||||||
|
|
||||||
lookups = (
|
|
||||||
('excellent', '🟢 Отлично'),
|
|
||||||
('good', '🟡 Хорошо'),
|
|
||||||
('acceptable', '🟠 Приемлемо'),
|
|
||||||
('poor', '🔴 Плохо'),
|
|
||||||
('very_poor', '🔴🔴 Очень плохо'),
|
|
||||||
('warning', '⚠️ Требует обновления'), # poor + very_poor
|
|
||||||
('no_warning', '✓ Готово к выгрузке'), # excellent + good
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Использование в админе:**
|
|
||||||
```
|
|
||||||
list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'categories')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Display Functions (admin_displays.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
def format_quality_badge(quality_level, show_icon=True):
|
|
||||||
"""HTML бейдж: <span class="badge bg-success">✓ Отлично</span>"""
|
|
||||||
|
|
||||||
def format_quality_display(quality_level, width, height, warning):
|
|
||||||
"""Полный индикатор: 🟢 Отлично (2150×2150px) или ⚠️ Требует обновления"""
|
|
||||||
|
|
||||||
def format_photo_quality_column(obj):
|
|
||||||
"""Для list_display в админе"""
|
|
||||||
first_photo = obj.photos.first()
|
|
||||||
return format_quality_display(...)
|
|
||||||
|
|
||||||
def format_photo_preview_with_quality(photo_obj):
|
|
||||||
"""Превью фото с индикатором качества"""
|
|
||||||
```
|
|
||||||
|
|
||||||
### Photo Inlines
|
|
||||||
|
|
||||||
Обновлены `ProductPhotoInline`, `ProductKitPhotoInline`, `ProductCategoryPhotoInline`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
readonly_fields = (..., 'quality_display')
|
|
||||||
|
|
||||||
def quality_display(self, obj):
|
|
||||||
"""Показывает качество в inline таблице"""
|
|
||||||
if not obj.pk:
|
|
||||||
return format_html('<span style="color: #999;">Сохраните фото</span>')
|
|
||||||
|
|
||||||
return format_quality_display(
|
|
||||||
obj.quality_level,
|
|
||||||
obj.width,
|
|
||||||
obj.height,
|
|
||||||
obj.quality_warning
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Product Admin Classes
|
|
||||||
|
|
||||||
Обновлены `ProductAdmin`, `ProductCategoryAdmin`, `ProductKitAdmin`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
list_display = (..., 'photo_with_quality', ...)
|
|
||||||
list_filter = (..., QualityLevelFilter, ...)
|
|
||||||
|
|
||||||
def photo_with_quality(self, obj):
|
|
||||||
"""Превью + цветной бейдж качества в списке"""
|
|
||||||
first_photo = obj.photos.first()
|
|
||||||
if not first_photo or not first_photo.image:
|
|
||||||
return format_html('<span style="color: #999;">Нет фото</span>')
|
|
||||||
|
|
||||||
# Flexbox контейнер с иконкой и фото
|
|
||||||
quality_indicator = format_quality_badge(first_photo.quality_level)
|
|
||||||
return format_html(
|
|
||||||
'<div style="display: flex; align-items: center; gap: 8px;">'
|
|
||||||
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover;" />'
|
|
||||||
'{}'
|
|
||||||
'</div>',
|
|
||||||
first_photo.image.url,
|
|
||||||
quality_indicator
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Admin Actions (новые)
|
|
||||||
|
|
||||||
```python
|
|
||||||
def show_poor_quality_photos(modeladmin, request, queryset):
|
|
||||||
"""Перенаправляет на список товаров с quality_warning=True"""
|
|
||||||
return redirect(f'...?photo_quality=warning')
|
|
||||||
|
|
||||||
def show_excellent_quality_photos(modeladmin, request, queryset):
|
|
||||||
"""Перенаправляет на список с excellent/good качеством"""
|
|
||||||
return redirect(f'...?photo_quality=no_warning')
|
|
||||||
|
|
||||||
def show_all_quality_levels(modeladmin, request, queryset):
|
|
||||||
"""Показывает статистику распределения качества"""
|
|
||||||
quality_stats = queryset.filter(photos__isnull=False).values(
|
|
||||||
'photos__quality_level'
|
|
||||||
).annotate(count=Count('id', distinct=True))
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 3: Фронтенд UI
|
|
||||||
|
|
||||||
### Template Tags (products/templatetags/quality_tags.py)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@register.filter
|
|
||||||
def quality_badge_mini(photo):
|
|
||||||
"""Маленький кружочек-значок в углу фото (🟢/🟡/🟠/🔴/⚠️)"""
|
|
||||||
|
|
||||||
@register.filter
|
|
||||||
def quality_badge_full(photo):
|
|
||||||
"""Полный бейдж: 🟢 Отлично (2150×2150px)"""
|
|
||||||
|
|
||||||
@register.filter
|
|
||||||
def quality_icon_only(photo):
|
|
||||||
"""Только символ для списков"""
|
|
||||||
|
|
||||||
@register.inclusion_tag('products/includes/quality_badge.html')
|
|
||||||
def quality_indicator(photo, show_size=False):
|
|
||||||
"""Включаемый тег для вывода индикатора в углу"""
|
|
||||||
# Возвращает контекст с всей информацией о качестве
|
|
||||||
```
|
|
||||||
|
|
||||||
### CSS Стили (static/css/quality_indicator.css)
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Ненавязчивое отображение */
|
|
||||||
.quality-badge-mini {
|
|
||||||
opacity: 0.8; /* Не отвлекает */
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-badge-mini:hover {
|
|
||||||
opacity: 1; /* Более видимо при наведении */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Компактные размеры для списков */
|
|
||||||
.photo-list-item .quality-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: -4px;
|
|
||||||
right: -4px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Отзывчивость */
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.quality-indicator {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Интеграция в шаблоны
|
|
||||||
|
|
||||||
#### product_detail.html
|
|
||||||
|
|
||||||
```django
|
|
||||||
{% load quality_tags %}
|
|
||||||
|
|
||||||
<!-- В сетке миниатюр: индикатор + полный бейдж -->
|
|
||||||
<div class="card photo-card-with-quality">
|
|
||||||
<div class="photo-container">
|
|
||||||
<img src="...">
|
|
||||||
{% quality_indicator photo %} <!-- В углу -->
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
...
|
|
||||||
{{ photo|quality_badge_full }} <!-- Под фото -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- В модальной галерее: качество в footer -->
|
|
||||||
<div class="modal-footer">
|
|
||||||
<div id="galleryQualityStatus">
|
|
||||||
<!-- Динамически обновляется JS -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**JavaScript для галереи:**
|
|
||||||
```javascript
|
|
||||||
photoCarousel.addEventListener('slid.bs.carousel', function(event) {
|
|
||||||
const photoInfo = photos[event.to];
|
|
||||||
|
|
||||||
// Обновляем статус качества при смене слайда
|
|
||||||
qualityStatusEl.innerHTML =
|
|
||||||
`<span class="badge bg-${info.color}">
|
|
||||||
${info.symbol} ${info.label} (${width}×${height}px)
|
|
||||||
</span>`;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### product_list.html
|
|
||||||
|
|
||||||
```django
|
|
||||||
{% load quality_tags %}
|
|
||||||
|
|
||||||
<div class="photo-list-item">
|
|
||||||
<img src="...">
|
|
||||||
<span class="quality-icon">{{ photo|quality_icon_only }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Показывает маленький значок (🟢/🟡/🟠/🔴/⚠️) в углу миниатюры.
|
|
||||||
|
|
||||||
#### productkit_detail.html
|
|
||||||
|
|
||||||
```django
|
|
||||||
{% load quality_tags %}
|
|
||||||
|
|
||||||
<div class="photo-card-with-quality">
|
|
||||||
<div class="photo-container">
|
|
||||||
<img src="...">
|
|
||||||
{% quality_indicator photo %}
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
{{ photo|quality_badge_full }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Файлы проекта
|
|
||||||
|
|
||||||
### Новые файлы
|
|
||||||
|
|
||||||
| Файл | Описание |
|
|
||||||
|------|---------|
|
|
||||||
| `myproject/products/templatetags/quality_tags.py` | Template tags для отображения качества |
|
|
||||||
| `myproject/products/templates/products/includes/quality_badge.html` | Шаблон включаемого тега |
|
|
||||||
| `myproject/static/css/quality_indicator.css` | CSS стили для индикаторов |
|
|
||||||
| `myproject/products/admin_displays.py` | Вспомогательные функции для админа |
|
|
||||||
| `myproject/products/validators/image_validators.py` | Валидаторы и расчёт качества |
|
|
||||||
|
|
||||||
### Модифицированные файлы
|
|
||||||
|
|
||||||
| Файл | Изменения |
|
|
||||||
|------|-----------|
|
|
||||||
| `myproject/products/admin.py` | QualityLevelFilter, actions, photo_with_quality методы |
|
|
||||||
| `myproject/products/models/photos.py` | quality_level и quality_warning поля |
|
|
||||||
| `myproject/products/utils/image_processor.py` | Возврат quality_level и quality_warning |
|
|
||||||
| `myproject/templates/base.html` | Подключение CSS для качества |
|
|
||||||
| `myproject/products/templates/products/product_detail.html` | Индикаторы в сетке и галерее |
|
|
||||||
| `myproject/products/templates/products/product_list.html` | Иконка качества в таблице |
|
|
||||||
| `myproject/products/templates/products/productkit_detail.html` | Индикаторы для комплектов |
|
|
||||||
|
|
||||||
### Migrations
|
|
||||||
|
|
||||||
| Файл | Описание |
|
|
||||||
|------|---------|
|
|
||||||
| `myproject/products/migrations/0003_productcategoryphoto_quality_level_and_more.py` | Добавляет поля в БД |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Для администратора
|
|
||||||
|
|
||||||
1. **Фильтрация товаров в админе:**
|
|
||||||
- Перейти в Products → Products
|
|
||||||
- Открыть фильтр "Качество фото"
|
|
||||||
- Выбрать нужный уровень (Отлично, Хорошо, Требует обновления и т.д.)
|
|
||||||
|
|
||||||
2. **Использование Actions:**
|
|
||||||
- Выбрать товары → Action → "Показать товары с фото требующими обновления"
|
|
||||||
- Система автоматически применит фильтр
|
|
||||||
|
|
||||||
3. **Просмотр статистики:**
|
|
||||||
- Action → "Показать статистику качества фото"
|
|
||||||
- Увидите распределение товаров по уровням качества
|
|
||||||
|
|
||||||
### Для пользователя (фронтенд)
|
|
||||||
|
|
||||||
1. **На странице товара:**
|
|
||||||
- Миниатюры фотографий показывают маленький значок качества в углу
|
|
||||||
- Под каждой миниатюрой видно "🟢 Отлично (2150×2150px)"
|
|
||||||
- При клике на фото открывается галерея с информацией о качестве текущего фото в footer
|
|
||||||
|
|
||||||
2. **В списке товаров:**
|
|
||||||
- Рядом с иконкой фото видна маленькая цветная точка (🟢/🟡/🟠/🔴)
|
|
||||||
- При наведении показывается полное название качества
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Гибкость системы
|
|
||||||
|
|
||||||
### Изменение порогов качества
|
|
||||||
|
|
||||||
**В settings.py:**
|
|
||||||
```python
|
|
||||||
IMAGE_QUALITY_LEVELS = {
|
|
||||||
'excellent': 0.90, # Вместо 0.95 - чуть менее строгий
|
|
||||||
'good': 0.65, # Вместо 0.70
|
|
||||||
'acceptable': 0.40,
|
|
||||||
'poor': 0.20,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ **Никакого кода не нужно менять** - система автоматически пересчитает все пороги.
|
|
||||||
|
|
||||||
### Изменение максимального размера фото
|
|
||||||
|
|
||||||
**В settings.py:**
|
|
||||||
```python
|
|
||||||
IMAGE_PROCESSING_CONFIG = {
|
|
||||||
'max_width': 2000, # Вместо 2160
|
|
||||||
'max_height': 2000,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
✅ **Все пороги автоматически пересчитаются:**
|
|
||||||
- Excellent: >= 1900px (вместо 2052px)
|
|
||||||
- Good: >= 1400px (вместо 1512px)
|
|
||||||
- И т.д.
|
|
||||||
|
|
||||||
### Добавление новых уровней качества
|
|
||||||
|
|
||||||
```python
|
|
||||||
IMAGE_QUALITY_LEVELS = {
|
|
||||||
...
|
|
||||||
'premium': 0.99, # Новый уровень!
|
|
||||||
}
|
|
||||||
|
|
||||||
IMAGE_QUALITY_LABELS = {
|
|
||||||
...
|
|
||||||
'premium': {
|
|
||||||
'label': 'Премиум',
|
|
||||||
'color': 'primary',
|
|
||||||
'icon': '⭐',
|
|
||||||
'recommendation': 'Премиум качество',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Система найдёт и использует новый уровень без изменений в коде.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Коммиты
|
|
||||||
|
|
||||||
### Commit 1: Phase 1
|
|
||||||
```
|
|
||||||
d15e7d9 fix: Исправить подмену фотографий при загрузке
|
|
||||||
```
|
|
||||||
- Удаление старых файлов перед сохранением
|
|
||||||
- Cleanup скрипт для удаления старых файлов из media/
|
|
||||||
|
|
||||||
### Commit 2: Phase 1
|
|
||||||
```
|
|
||||||
622e17a feat: Реализовать систему оценки качества фотографий товаров
|
|
||||||
```
|
|
||||||
- Валидаторы и расчёт качества
|
|
||||||
- Поля в БД (quality_level, quality_warning)
|
|
||||||
- Integration с ImageProcessor
|
|
||||||
|
|
||||||
### Commit 3: Phase 2
|
|
||||||
```
|
|
||||||
[уже в истории]
|
|
||||||
```
|
|
||||||
- Admin interface с фильтрами
|
|
||||||
- Visual indicators в админе
|
|
||||||
- Actions для поиска товаров
|
|
||||||
|
|
||||||
### Commit 4: Phase 3
|
|
||||||
```
|
|
||||||
2d344ef feat: Фаза 3 - Добавить индикаторы качества фото на фронтенд
|
|
||||||
```
|
|
||||||
- Template tags для качества
|
|
||||||
- CSS стили для индикаторов
|
|
||||||
- Integration в product_detail, product_list, productkit_detail
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
### Phase 1
|
|
||||||
|
|
||||||
1. Загрузить фото 2160×2160px → quality_level должна быть "excellent", warning=False
|
|
||||||
2. Загрузить фото 1500×1500px → "good"
|
|
||||||
3. Загрузить фото 400×400px → "poor", warning=True
|
|
||||||
4. Изменить max_width в settings на 2000
|
|
||||||
5. Перезагрузить БД → все фото пересчитаны с новыми порогами
|
|
||||||
|
|
||||||
### Phase 2
|
|
||||||
|
|
||||||
1. Перейти в Products → Products в админе
|
|
||||||
2. Применить фильтр "Требует обновления" → видны только товары с warning=True
|
|
||||||
3. Выбрать товар, кликнуть Action → "Показать статистику"
|
|
||||||
4. Убедиться что видна статистика по разным уровням качества
|
|
||||||
|
|
||||||
### Phase 3
|
|
||||||
|
|
||||||
1. Открыть страницу товара → видны индикаторы в углу миниатюр
|
|
||||||
2. Кликнуть на фото → открыть галерею → в footer видно качество текущего фото
|
|
||||||
3. Переключить слайд → качество обновляется в footer
|
|
||||||
4. Открыть список товаров → видны маленькие иконки качества рядом с фото
|
|
||||||
5. Проверить мобильный → индикаторы должны быть компактными
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Создана **полностью гибкая и модульная система** для оценки качества фотографий:
|
|
||||||
|
|
||||||
- ✅ **100% читает из settings** - изменения без редактирования кода
|
|
||||||
- ✅ **Three-tier implementation** - Backend logic, Admin UI, Frontend display
|
|
||||||
- ✅ **Ненавязчивый дизайн** - не отвлекает от основного контента
|
|
||||||
- ✅ **Полная интеграция** - работает со всеми моделями фото
|
|
||||||
- ✅ **Производительность** - использует индексы БД для быстрой фильтрации
|
|
||||||
|
|
||||||
System is **production-ready** и готова к использованию.
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
# Система оценки качества фотографий товаров - ФАЗА 1 ✅ ЗАВЕРШЕНА
|
|
||||||
|
|
||||||
## Что реализовано
|
|
||||||
|
|
||||||
### 1. Конфигурация в settings.py
|
|
||||||
```python
|
|
||||||
IMAGE_QUALITY_LEVELS = {
|
|
||||||
'excellent': 0.95, # >= 95% от max (если max=2160 → >= 2052px)
|
|
||||||
'good': 0.70, # >= 70% от max (если max=2160 → >= 1512px)
|
|
||||||
'acceptable': 0.40, # >= 40% от max (если max=2160 → >= 864px)
|
|
||||||
'poor': 0.20, # >= 20% от max (если max=2160 → >= 432px)
|
|
||||||
# < 20% = very_poor
|
|
||||||
}
|
|
||||||
|
|
||||||
IMAGE_QUALITY_LABELS = {
|
|
||||||
'excellent': {'label': 'Отлично', 'color': 'success', 'recommendation': '...'},
|
|
||||||
'good': {'label': 'Хорошо', 'color': 'info', 'recommendation': '...'},
|
|
||||||
'acceptable': {'label': 'Приемлемо', 'color': 'warning', 'recommendation': '...'},
|
|
||||||
'poor': {'label': 'Плохо', 'color': 'danger', 'recommendation': '...'},
|
|
||||||
'very_poor': {'label': 'Очень плохо', 'color': 'danger', 'recommendation': '...'},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Валидатор (validators/image_validators.py)
|
|
||||||
**Полностью гибкий валидатор, который:**
|
|
||||||
- Динамически читает max размеры из `IMAGE_PROCESSING_CONFIG`
|
|
||||||
- Вычисляет пороги как процент от максимума
|
|
||||||
- Определяет уровень качества для любого размера изображения
|
|
||||||
|
|
||||||
**Функции:**
|
|
||||||
```python
|
|
||||||
# Получить максимальный размер из конфиг
|
|
||||||
get_max_dimension_from_config() → 2160
|
|
||||||
|
|
||||||
# Определить качество на основе размеров
|
|
||||||
get_image_quality_level(width, height) → ('good', False)
|
|
||||||
|
|
||||||
# Получить информацию о уровне
|
|
||||||
get_quality_info('excellent') → {label, color, recommendation, ...}
|
|
||||||
|
|
||||||
# Валидация фото для UI
|
|
||||||
validate_product_image(file) → {valid, width, height, quality_level, message, error}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Пример работы:**
|
|
||||||
```python
|
|
||||||
# Если вы загружаете фото 546×546 (а max=2160):
|
|
||||||
quality_level, needs_update = get_image_quality_level(546, 546)
|
|
||||||
# Результат: ('acceptable', False)
|
|
||||||
# Расчет: 546/2160 = 0.253 (25.3%)
|
|
||||||
# 25.3% >= 40%? Нет, >= 20%? Да → poor
|
|
||||||
# На самом деле: 25.3% >= 40%? Нет, но >= 20%? Да → poor
|
|
||||||
# Хм, давайте пересчитаем:
|
|
||||||
# - excellent: 546/2160 = 0.253 >= 0.95? Нет
|
|
||||||
# - good: 0.253 >= 0.70? Нет
|
|
||||||
# - acceptable: 0.253 >= 0.40? Нет
|
|
||||||
# - poor: 0.253 >= 0.20? Да ✓
|
|
||||||
# Результат: ('poor', True) ← требует обновления
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Модели (ProductPhoto, ProductKitPhoto, ProductCategoryPhoto)
|
|
||||||
**Добавлены новые поля:**
|
|
||||||
```python
|
|
||||||
quality_level = CharField(
|
|
||||||
choices=[
|
|
||||||
('excellent', 'Отлично (>= 2052px)'),
|
|
||||||
('good', 'Хорошо (1512-2051px)'),
|
|
||||||
('acceptable', 'Приемлемо (864-1511px)'),
|
|
||||||
('poor', 'Плохо (432-863px)'),
|
|
||||||
('very_poor', 'Очень плохо (< 432px)'),
|
|
||||||
],
|
|
||||||
default='acceptable',
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
quality_warning = BooleanField(
|
|
||||||
default=False, # True для poor и very_poor
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Индексы для быстрого поиска:**
|
|
||||||
```python
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['quality_level']),
|
|
||||||
models.Index(fields=['quality_warning']),
|
|
||||||
models.Index(fields=['quality_warning', 'product']), # Товары требующие обновления
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Image Processor (image_processor.py)
|
|
||||||
**Обновлен метод process_image:**
|
|
||||||
```python
|
|
||||||
def process_image(image_file, base_path, entity_id, photo_id):
|
|
||||||
# Раньше возвращал:
|
|
||||||
# {'original': '...', 'large': '...', 'medium': '...', 'thumbnail': '...'}
|
|
||||||
|
|
||||||
# Теперь возвращает дополнительно:
|
|
||||||
{
|
|
||||||
'original': '...',
|
|
||||||
'large': '...',
|
|
||||||
'medium': '...',
|
|
||||||
'thumbnail': '...',
|
|
||||||
'width': 1920,
|
|
||||||
'height': 1080,
|
|
||||||
'quality_level': 'excellent',
|
|
||||||
'quality_warning': False,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Сохранение фото (photos.py -> save())
|
|
||||||
**Автоматическое определение качества:**
|
|
||||||
- При создании нового фото → вычисляет quality_level и quality_warning
|
|
||||||
- При обновлении фото → пересчитывает качество
|
|
||||||
- Сохраняет все три поля atomically в БД
|
|
||||||
|
|
||||||
## Как это работает (пример)
|
|
||||||
|
|
||||||
### Сценарий: Загрузка фото 546×546px
|
|
||||||
|
|
||||||
1. **Пользователь загружает фото** через форму продукта
|
|
||||||
2. **Вызывается ProductPhoto.save()**
|
|
||||||
3. **ImageProcessor.process_image()** обрабатывает фото:
|
|
||||||
- Открывает изображение, получает размеры 546×546
|
|
||||||
- **Вызывает get_image_quality_level(546, 546)**
|
|
||||||
- Вычисляет: max=2160 (из settings), percent=546/2160=0.253
|
|
||||||
- Сравнивает с пороги: 0.253 >= 0.20? **Да** → 'poor'
|
|
||||||
- Возвращает: ('poor', True)
|
|
||||||
- Сохраняет все размеры (original, large, medium, thumb)
|
|
||||||
- Возвращает обработанные пути + quality info
|
|
||||||
4. **ProductPhoto.save()** получает результат:
|
|
||||||
```python
|
|
||||||
processed_paths = {
|
|
||||||
'original': 'products/2/7/original.jpg',
|
|
||||||
'quality_level': 'poor',
|
|
||||||
'quality_warning': True,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
5. **Сохраняет в БД:**
|
|
||||||
```python
|
|
||||||
photo.image = 'products/2/7/original.jpg'
|
|
||||||
photo.quality_level = 'poor'
|
|
||||||
photo.quality_warning = True
|
|
||||||
photo.save()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Результат в БД:
|
|
||||||
```
|
|
||||||
ProductPhoto:
|
|
||||||
- id: 7
|
|
||||||
- product_id: 2
|
|
||||||
- image: products/2/7/original.jpg
|
|
||||||
- quality_level: 'poor' 🔴
|
|
||||||
- quality_warning: True ← Требует обновления!
|
|
||||||
- order: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Гибкость конфигурации
|
|
||||||
|
|
||||||
### Пример 1: Вы изменили max_width с 2160 на 3000
|
|
||||||
```python
|
|
||||||
# В settings.py
|
|
||||||
IMAGE_PROCESSING_CONFIG = {
|
|
||||||
'formats': {
|
|
||||||
'original': {
|
|
||||||
'max_width': 3000, # Было 2160
|
|
||||||
'max_height': 3000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Система АВТОМАТИЧЕСКИ пересчитает:
|
|
||||||
# excellent: 0.95 * 3000 = 2850px
|
|
||||||
# good: 0.70 * 3000 = 2100px
|
|
||||||
# acceptable: 0.40 * 3000 = 1200px
|
|
||||||
# poor: 0.20 * 3000 = 600px
|
|
||||||
|
|
||||||
# Код не менялся! ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример 2: Вы изменили пороги качества
|
|
||||||
```python
|
|
||||||
# Было:
|
|
||||||
IMAGE_QUALITY_LEVELS = {
|
|
||||||
'excellent': 0.95,
|
|
||||||
'good': 0.70,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Стало:
|
|
||||||
IMAGE_QUALITY_LEVELS = {
|
|
||||||
'excellent': 0.90, # Жестче
|
|
||||||
'good': 0.60, # Жестче
|
|
||||||
}
|
|
||||||
|
|
||||||
# Система АВТОМАТИЧЕСКИ переклассифицирует новые загрузки
|
|
||||||
# Старые фото останутся как есть (можно переклассифицировать через management команду)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что дальше (Фаза 2)
|
|
||||||
|
|
||||||
После создания миграций, нужно реализовать:
|
|
||||||
|
|
||||||
### Phase 2: Admin интерфейс
|
|
||||||
1. **Фильтры в админке**:
|
|
||||||
- По качеству (excellent, good, acceptable, poor, very_poor)
|
|
||||||
- Товары требующие обновления фото (quality_warning=True)
|
|
||||||
|
|
||||||
2. **Визуальные индикаторы**:
|
|
||||||
- Цветные иконки в списке товаров (🟢 Отлично, 🟡 Хорошо, 🟠 Приемлемо, 🔴 Плохо)
|
|
||||||
- Action для поиска товаров требующих обновления
|
|
||||||
|
|
||||||
3. **Админ-дисплеи**:
|
|
||||||
- Форматирование качества в таблицах
|
|
||||||
- Цветные бэджи
|
|
||||||
|
|
||||||
### Phase 3: Фронтенд UI
|
|
||||||
1. **Форма загрузки**:
|
|
||||||
- Preview фото с индикатором качества
|
|
||||||
- Сообщение о рекомендации
|
|
||||||
- Информация о размерах
|
|
||||||
|
|
||||||
2. **Список товаров**:
|
|
||||||
- Иконка качества для каждого фото
|
|
||||||
- Подсказка при наведении
|
|
||||||
|
|
||||||
## Структура файлов (после миграции)
|
|
||||||
|
|
||||||
```
|
|
||||||
myproject/
|
|
||||||
├── myproject/
|
|
||||||
│ └── settings.py ← IMAGE_QUALITY_LEVELS, IMAGE_QUALITY_LABELS
|
|
||||||
├── products/
|
|
||||||
│ ├── models/
|
|
||||||
│ │ └── photos.py ← ProductPhoto, ProductKitPhoto, ProductCategoryPhoto с новыми полями
|
|
||||||
│ ├── validators/
|
|
||||||
│ │ └── image_validators.py ← Новый файл! Вся гибкая логика
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ └── image_processor.py ← Обновлен process_image()
|
|
||||||
│ └── migrations/
|
|
||||||
│ └── XXXX_add_photo_quality_assessment.py ← НУЖНА ВАША МИГРАЦИЯ!
|
|
||||||
```
|
|
||||||
|
|
||||||
## Коммит
|
|
||||||
```
|
|
||||||
622e17a feat: Реализовать систему оценки качества фотографий товаров (Фаза 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Следующие шаги
|
|
||||||
|
|
||||||
1. **Создать миграцию** через:
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations products --name add_photo_quality_assessment
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Применить миграцию**:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Протестировать вычисление качества**:
|
|
||||||
```python
|
|
||||||
python manage.py shell
|
|
||||||
>>> from products.validators.image_validators import get_image_quality_level
|
|
||||||
>>> get_image_quality_level(546, 546)
|
|
||||||
('poor', True)
|
|
||||||
>>> get_image_quality_level(2160, 2160)
|
|
||||||
('excellent', False)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Загрузить фото к товару** и проверить что quality_level и quality_warning автоматически заполнены в админке
|
|
||||||
|
|
||||||
5. **Приступить к Фазе 2** - реализовать admin интерфейс
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Фаза 1 завершена! 🎉 Система полностью готова к расширению.**
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
# Исправление создания резервов для заказов
|
|
||||||
|
|
||||||
## 🐛 Проблема
|
|
||||||
|
|
||||||
При создании нового заказа со статусом "Черновик" (или любым другим) резервы товаров **не создавались**.
|
|
||||||
|
|
||||||
### Причина
|
|
||||||
|
|
||||||
**Неправильный порядок сохранения в `orders/views.py`:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def order_create(request):
|
|
||||||
# ...
|
|
||||||
order.save() # ← 1. Сигнал reserve_stock_on_order_create срабатывает
|
|
||||||
# НО instance.items.all() пустой!
|
|
||||||
|
|
||||||
formset.save() # ← 2. OrderItem создаются ПОСЛЕ
|
|
||||||
# Сигнал больше не сработает
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что происходило:**
|
|
||||||
1. `order.save()` → сигнал `reserve_stock_on_order_create` срабатывал
|
|
||||||
2. Сигнал проверял `instance.items.all()` → **ПУСТО** (OrderItem ещё не созданы)
|
|
||||||
3. Цикл `for item in instance.items.all()` не выполнялся
|
|
||||||
4. Резервы НЕ создавались
|
|
||||||
5. `formset.save()` → OrderItem создавались, но сигнал уже отработал
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Решение #1: Создание резервов через сигнал OrderItem
|
|
||||||
|
|
||||||
### Изменённый файл: `inventory/signals.py`
|
|
||||||
|
|
||||||
**Сигнал:** `update_reservation_on_item_change` (строки 415-452)
|
|
||||||
|
|
||||||
### Было (ОШИБОЧНО):
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender=OrderItem)
|
|
||||||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|
||||||
if created:
|
|
||||||
return # ❌ Новые позиции игнорировались!
|
|
||||||
|
|
||||||
# Код обновления резервов...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Стало (ИСПРАВЛЕНО):
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender=OrderItem)
|
|
||||||
def update_reservation_on_item_change(sender, instance, created, **kwargs):
|
|
||||||
"""
|
|
||||||
При создании или изменении позиции заказа управляем резервами.
|
|
||||||
|
|
||||||
Покрывает все сценарии:
|
|
||||||
- Создание заказа с товарами → создаёт резервы
|
|
||||||
- Редактирование + добавление товаров → создаёт резервы для новых
|
|
||||||
- Изменение количества → обновляет резервы
|
|
||||||
"""
|
|
||||||
# ✅ Убрали раннее возвращение для created=True
|
|
||||||
|
|
||||||
reservation = Reservation.objects.filter(
|
|
||||||
order_item=instance,
|
|
||||||
status='reserved'
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if reservation:
|
|
||||||
# Резерв существует - обновляем количество
|
|
||||||
reservation.quantity = Decimal(str(instance.quantity))
|
|
||||||
reservation.save()
|
|
||||||
else:
|
|
||||||
# Резерва нет - создаём новый
|
|
||||||
warehouse = instance.order.pickup_warehouse or Warehouse.objects.filter(is_active=True).first()
|
|
||||||
|
|
||||||
if warehouse:
|
|
||||||
product = instance.product if instance.product else instance.product_kit
|
|
||||||
|
|
||||||
if product:
|
|
||||||
Reservation.objects.create(
|
|
||||||
order_item=instance,
|
|
||||||
product=product,
|
|
||||||
warehouse=warehouse,
|
|
||||||
quantity=Decimal(str(instance.quantity)),
|
|
||||||
status='reserved'
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ключевые изменения:
|
|
||||||
1. ✅ Убрали `if created: return` - сигнал работает для ВСЕХ сохранений OrderItem
|
|
||||||
2. ✅ Резервы создаются при добавлении OrderItem (независимо от способа создания)
|
|
||||||
3. ✅ При редактировании только обновляются (если резерв уже есть)
|
|
||||||
4. ✅ Работает для любого статуса заказа (Черновик, Новый, и т.д.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Решение #2: Безопасное освобождение резервов при удалении
|
|
||||||
|
|
||||||
### Изменённый файл: `inventory/signals.py`
|
|
||||||
|
|
||||||
**Сигнал:** `release_stock_on_order_delete` (строки 392-413)
|
|
||||||
|
|
||||||
### Проблема с `pre_delete`:
|
|
||||||
|
|
||||||
**Сценарий риска:**
|
|
||||||
1. Сигнал `pre_delete` срабатывает → резервы освобождаются
|
|
||||||
2. Удаление Order **ПАДАЕТ** с ошибкой (например, из-за ограничений БД)
|
|
||||||
3. **Результат:** Order не удалён, но резервы освобождены! ❌ Расхождение!
|
|
||||||
|
|
||||||
### Было (РИСКОВАННО):
|
|
||||||
```python
|
|
||||||
@receiver(pre_delete, sender=Order)
|
|
||||||
def release_stock_on_order_delete(sender, instance, **kwargs):
|
|
||||||
reservations = Reservation.objects.filter(
|
|
||||||
order_item__order=instance,
|
|
||||||
status='reserved'
|
|
||||||
)
|
|
||||||
|
|
||||||
# ❌ Освобождаем резервы ДО удаления
|
|
||||||
# Если удаление упадёт - резервы уже изменены!
|
|
||||||
for res in reservations:
|
|
||||||
res.status = 'released'
|
|
||||||
res.released_at = timezone.now()
|
|
||||||
res.save()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Стало (БЕЗОПАСНО):
|
|
||||||
```python
|
|
||||||
@receiver(pre_delete, sender=Order)
|
|
||||||
@transaction.atomic
|
|
||||||
def release_stock_on_order_delete(sender, instance, **kwargs):
|
|
||||||
"""
|
|
||||||
При удалении заказа освобождаем резервы ТОЛЬКО после успешного коммита.
|
|
||||||
"""
|
|
||||||
# ✅ Получаем резервы ДО удаления (пока Order существует)
|
|
||||||
# Используем list() чтобы выполнить запрос сейчас
|
|
||||||
reservations_to_release = list(
|
|
||||||
Reservation.objects.filter(
|
|
||||||
order_item__order=instance,
|
|
||||||
status='reserved'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# ✅ Освобождаем резервы ПОСЛЕ успешного коммита
|
|
||||||
def release_reservations():
|
|
||||||
for res in reservations_to_release:
|
|
||||||
res.status = 'released'
|
|
||||||
res.released_at = timezone.now()
|
|
||||||
res.save()
|
|
||||||
|
|
||||||
transaction.on_commit(release_reservations)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ключевые изменения:
|
|
||||||
1. ✅ Добавлен `@transaction.atomic` - гарантирует транзакцию
|
|
||||||
2. ✅ Используем `list()` для получения резервов ДО удаления
|
|
||||||
3. ✅ Используем `transaction.on_commit()` - освобождаем резервы ТОЛЬКО если удаление успешно
|
|
||||||
4. ✅ Гарантия целостности данных - нет риска расхождений
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Покрытие всех сценариев
|
|
||||||
|
|
||||||
| Сценарий | OrderItem | created | Резерв существует? | Действие |
|
|
||||||
|----------|-----------|---------|-------------------|----------|
|
|
||||||
| **Создание заказа с товарами** | Новый | True | Нет | Создать резерв ✅ |
|
|
||||||
| **Создание заказа без товаров** | - | - | - | Ничего ✅ |
|
|
||||||
| **Редактирование: добавить товар** | Новый | True | Нет | Создать резерв ✅ |
|
|
||||||
| **Редактирование: изменить количество** | Старый | False | Да | Обновить резерв ✅ |
|
|
||||||
| **Повторное сохранение заказа** | Старый | False | Да | Обновить резерв ✅ |
|
|
||||||
| **Удаление заказа (успешно)** | - | - | - | Освободить резервы ✅ |
|
|
||||||
| **Удаление заказа (с ошибкой)** | - | - | - | Резервы НЕ освобождаются ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Преимущества решения
|
|
||||||
|
|
||||||
### Решение #1 (OrderItem сигнал):
|
|
||||||
- ✅ **Универсальность** - работает для любого способа создания OrderItem
|
|
||||||
- ✅ **Простота** - минимальное изменение существующего сигнала
|
|
||||||
- ✅ **Независимость от статуса** - резервы создаются для любого статуса заказа
|
|
||||||
- ✅ **Гибкость** - можно добавлять товары при редактировании
|
|
||||||
|
|
||||||
### Решение #2 (transaction.on_commit):
|
|
||||||
- ✅ **Целостность данных** - резервы освобождаются только при успешном удалении
|
|
||||||
- ✅ **Безопасность** - нет риска расхождений при ошибках
|
|
||||||
- ✅ **Прозрачность** - очевидная логика работы
|
|
||||||
- ✅ **Надёжность** - гарантии через транзакции
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Как проверить
|
|
||||||
|
|
||||||
### Тест 1: Создание заказа с товарами
|
|
||||||
1. Создайте новый заказ со статусом "Черновик"
|
|
||||||
2. Добавьте 2 товара по 10 штук каждый
|
|
||||||
3. Сохраните заказ
|
|
||||||
4. **Проверка:** В таблице `Reservation` должно быть 2 записи со `status='reserved'`
|
|
||||||
|
|
||||||
### Тест 2: Создание заказа без товаров
|
|
||||||
1. Создайте новый заказ без товаров
|
|
||||||
2. Сохраните заказ
|
|
||||||
3. **Проверка:** Резервы не создались (это нормально)
|
|
||||||
|
|
||||||
### Тест 3: Добавление товара при редактировании
|
|
||||||
1. Откройте существующий заказ
|
|
||||||
2. Добавьте новый товар (5 штук)
|
|
||||||
3. Сохраните заказ
|
|
||||||
4. **Проверка:** Создался новый резерв для добавленного товара
|
|
||||||
|
|
||||||
### Тест 4: Изменение количества
|
|
||||||
1. Откройте существующий заказ с товарами
|
|
||||||
2. Измените количество товара с 10 на 15
|
|
||||||
3. Сохраните заказ
|
|
||||||
4. **Проверка:** Резерв обновился (quantity=15)
|
|
||||||
|
|
||||||
### Тест 5: Удаление заказа
|
|
||||||
1. Удалите заказ с товарами
|
|
||||||
2. **Проверка:** Резервы переведены в `status='released'`
|
|
||||||
3. Попробуйте удалить заказ с нарушением ограничений БД
|
|
||||||
4. **Проверка:** Если удаление упало - резервы НЕ изменились
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Связанные файлы
|
|
||||||
|
|
||||||
- `myproject/inventory/signals.py` - оба исправления
|
|
||||||
- `myproject/orders/views.py` - порядок сохранения (не изменялся)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Дата исправления: 2024-12-01
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
# ✅ СТАТУС: Исправление Race Condition завершено
|
|
||||||
|
|
||||||
## Дата: 2025-11-02
|
|
||||||
## Коммиты: c7bf23c, 8bec582
|
|
||||||
## Статус: ✅ ГОТОВО К ТЕСТИРОВАНИЮ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Что было сделано
|
|
||||||
|
|
||||||
### Проблема, которая была исправлена
|
|
||||||
|
|
||||||
**URL:** http://grach.localhost:8000/products/kits/4/update/
|
|
||||||
|
|
||||||
**Симптом:** Сохранённые значения корректировки цены не отображались надёжно
|
|
||||||
- **1/10 раз:** Значение отображалось правильно ✅
|
|
||||||
- **9/10 раз:** Поле было пустым ❌
|
|
||||||
|
|
||||||
**Причина:** Race condition - при загрузке значения в input-поле срабатывали события, которые вызывали функцию перезаписи значений со значениями по умолчанию, стирая загруженные данные.
|
|
||||||
|
|
||||||
### Решение: Трёхуровневая защита от race condition
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Уровень 1: Подавление событий
|
|
||||||
let isLoadingAdjustmentValues = false;
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
if (isLoadingAdjustmentValues) return; // ← Пропуск события
|
|
||||||
calculateFinalPrice();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Уровень 2: Защита скрытых полей
|
|
||||||
if (!isInitializing) { // ← Проверка флага
|
|
||||||
adjustmentTypeInput.value = adjustmentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Уровень 3: Синхронизация с браузером
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
isInitializing = false; // ← В конце frame cycle
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Технические изменения
|
|
||||||
|
|
||||||
### Файл: `productkit_edit.html`
|
|
||||||
|
|
||||||
| Строка | Изменение | Описание |
|
|
||||||
|--------|-----------|---------|
|
|
||||||
| 435 | Добавлена переменная | `let isLoadingAdjustmentValues = false;` |
|
|
||||||
| 683-700 | Защита event listeners | Добавлена проверка `if (isLoadingAdjustmentValues) return;` |
|
|
||||||
| 912-948 | Переработана загрузка значений | Использование флагов и requestAnimationFrame |
|
|
||||||
|
|
||||||
### Размер изменений
|
|
||||||
- Строк добавлено: ~30
|
|
||||||
- Строк удалено: ~10
|
|
||||||
- Чистое добавление функциональности: ~20 строк
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Документация
|
|
||||||
|
|
||||||
Три полных документа созданы для понимания и тестирования:
|
|
||||||
|
|
||||||
### 1. **FINAL_FIX_SUMMARY.md** (Финальное резюме)
|
|
||||||
- 📝 Краткое описание проблемы и решения
|
|
||||||
- ✅ Преимущества решения
|
|
||||||
- 🔄 Интеграция с существующей системой
|
|
||||||
- 📍 Что дальше
|
|
||||||
|
|
||||||
**Размер:** ~250 строк | **Время чтения:** 5 минут
|
|
||||||
|
|
||||||
### 2. **ADJUSTMENT_VALUE_FIX_TESTING.md** (План тестирования)
|
|
||||||
- 🧪 4 полных тестовых сценария
|
|
||||||
- 📊 Таблица результатов
|
|
||||||
- 🐛 Возможные проблемы и решения
|
|
||||||
- 🔍 Проверка логирования в консоли
|
|
||||||
|
|
||||||
**Размер:** ~350 строк | **Время на тестирование:** 10 минут
|
|
||||||
|
|
||||||
### 3. **TECHNICAL_RACE_CONDITION_FIX.md** (Технический анализ)
|
|
||||||
- 🎓 Глубокий анализ race condition
|
|
||||||
- 🔄 Последовательность выполнения кода (с визуализацией)
|
|
||||||
- 🛡️ Объяснение каждого уровня защиты
|
|
||||||
- 📚 Смежные темы и альтернативные подходы
|
|
||||||
|
|
||||||
**Размер:** ~500 строк | **Время чтения:** 15 минут
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Контрольный список перед использованием
|
|
||||||
|
|
||||||
- [x] Изменения закоммичены в git (коммит c7bf23c)
|
|
||||||
- [x] Документация создана (коммит 8bec582)
|
|
||||||
- [x] Логирование добавлено для отладки
|
|
||||||
- [x] Обратная совместимость сохранена
|
|
||||||
- [x] Все 3 уровня защиты реализованы
|
|
||||||
- [x] Использует браузерные APIs (requestAnimationFrame)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Как начать тестирование
|
|
||||||
|
|
||||||
### Минимальный тест (2 минуты)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Открыть: http://grach.localhost:8000/products/kits/4/update/
|
|
||||||
2. Нажать: Ctrl+F5 (очистить кэш)
|
|
||||||
3. Проверить: Блок "НА СКОЛЬКО БЫЛА ИЗМЕНЕНА ЦЕНА" должен показать "Увеличить на %: 10"
|
|
||||||
4. Результат: ✅ ПРОЙДЕНО (если значение 10 видно)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Полный тест (10 минут)
|
|
||||||
|
|
||||||
Смотрите документ **ADJUSTMENT_VALUE_FIX_TESTING.md**
|
|
||||||
|
|
||||||
Включает:
|
|
||||||
- Проверка 10 раз (вместо 1 раза из 10)
|
|
||||||
- Проверка логирования в консоли
|
|
||||||
- Проверка редактирования
|
|
||||||
- Проверка разных типов корректировки
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Как проверить логирование
|
|
||||||
|
|
||||||
```
|
|
||||||
1. F12 (открыть DevTools)
|
|
||||||
2. Перейти на вкладку "Console"
|
|
||||||
3. Ctrl+F5 (перезагрузить страницу)
|
|
||||||
4. Смотреть логи (должны появиться в таком порядке):
|
|
||||||
|
|
||||||
✅ "Loading saved adjustment values: {type: 'increase_percent', value: 10}"
|
|
||||||
✅ "isLoadingAdjustmentValues = true, suppressing input/change events"
|
|
||||||
✅ "Loaded increase_percent: 10"
|
|
||||||
✅ "isLoadingAdjustmentValues = false, events are enabled again"
|
|
||||||
✅ "calculateFinalPrice: calculating..."
|
|
||||||
✅ "Initialization complete, isInitializing = false"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Метрики исправления
|
|
||||||
|
|
||||||
| Метрика | До | После | Улучшение |
|
|
||||||
|---------|----|----|-----------|
|
|
||||||
| **Надёжность отображения** | 10% (1/10) | 99%+ (10/10) | **+990%** |
|
|
||||||
| **Код понимаемости** | Слабая | Хорошая | +++ |
|
|
||||||
| **Возможность отладки** | Нет логирования | Полное логирование | +++ |
|
|
||||||
| **Производительность** | OK | OK (не изменилась) | ✅ |
|
|
||||||
| **Совместимость** | OK | OK (полная) | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Цели исправления
|
|
||||||
|
|
||||||
| Цель | Статус |
|
|
||||||
|------|--------|
|
|
||||||
| Исправить race condition | ✅ ВЫПОЛНЕНО |
|
|
||||||
| Надёжность 99%+ | ✅ ВЫПОЛНЕНО |
|
|
||||||
| Добавить логирование | ✅ ВЫПОЛНЕНО |
|
|
||||||
| Сохранить совместимость | ✅ ВЫПОЛНЕНО |
|
|
||||||
| Документировать решение | ✅ ВЫПОЛНЕНО |
|
|
||||||
| Создать план тестирования | ✅ ВЫПОЛНЕНО |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Git информация
|
|
||||||
|
|
||||||
### Основной коммит с исправлением
|
|
||||||
```
|
|
||||||
Commit: c7bf23c
|
|
||||||
Title: fix: Улучшить загрузку сохранённых значений корректировки цены
|
|
||||||
Files: productkit_edit.html (3 основных изменения)
|
|
||||||
Lines: ~30 добавлено, ~10 удалено
|
|
||||||
```
|
|
||||||
|
|
||||||
### Коммит с документацией
|
|
||||||
```
|
|
||||||
Commit: 8bec582
|
|
||||||
Title: docs: Добавить документацию по исправлению race condition
|
|
||||||
Files: 3 новых документа (~875 строк)
|
|
||||||
- FINAL_FIX_SUMMARY.md
|
|
||||||
- ADJUSTMENT_VALUE_FIX_TESTING.md
|
|
||||||
- TECHNICAL_RACE_CONDITION_FIX.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### История последних коммитов
|
|
||||||
```
|
|
||||||
8bec582 docs: Добавить документацию по исправлению race condition
|
|
||||||
c7bf23c fix: Улучшить загрузку сохранённых значений корректировки цены
|
|
||||||
c228f80 fix: Заполнять скрытые поля корректировки значениями из БД
|
|
||||||
3c62cce fix: Загружать сохранённые значения корректировки цены
|
|
||||||
045f6a4 fix: Удалить вызов старого валидатора ценообразования
|
|
||||||
390d547 feat: Добавить валидацию для заполнения одного поля корректировки
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 Чему можно научиться
|
|
||||||
|
|
||||||
Это исправление демонстрирует:
|
|
||||||
1. **Race Condition Detection** - как найти и диагностировать race condition
|
|
||||||
2. **Event Suppression** - как подавлять события флагом
|
|
||||||
3. **Defense in Depth** - несколько уровней защиты лучше чем один
|
|
||||||
4. **Browser APIs** - использование requestAnimationFrame для синхронизации
|
|
||||||
5. **Logging** - как логирование помогает отладке и пониманию
|
|
||||||
6. **JavaScript Advanced** - async/await, Promise, Events
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Контакты для помощи
|
|
||||||
|
|
||||||
### Если тестирование не прошло успешно
|
|
||||||
|
|
||||||
1. **Проверьте логи:** F12 → Console → Ctrl+F5
|
|
||||||
2. **Убедитесь что коммиты развёрнуты:** `git log -1`
|
|
||||||
3. **Очистите кэш:** Ctrl+Shift+Delete (браузер)
|
|
||||||
4. **Проверьте URL:** http://grach.localhost:8000/products/kits/4/update/
|
|
||||||
5. **Проверьте тенант:** Должен быть "grach"
|
|
||||||
|
|
||||||
### Если логирование не показывается
|
|
||||||
|
|
||||||
1. Проверьте что консоль не отфильтрована
|
|
||||||
2. Нажмите Ctrl+F5 на странице
|
|
||||||
3. Фильтр по "Loading saved" в консоли
|
|
||||||
4. Убедитесь что используется правильный файл (productkit_edit.html)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Итоговый статус
|
|
||||||
|
|
||||||
```
|
|
||||||
╔════════════════════════════════════════════╗
|
|
||||||
║ ✅ ИСПРАВЛЕНИЕ ЗАВЕРШЕНО И ГОТОВО ║
|
|
||||||
║ ║
|
|
||||||
║ Проблема: Race condition ║
|
|
||||||
║ Надёжность: 1/10 → 10/10 (99%+) ║
|
|
||||||
║ Решение: Трёхуровневая защита ║
|
|
||||||
║ Статус: ✅ ГОТОВО К ТЕСТИРОВАНИЮ ║
|
|
||||||
║ ║
|
|
||||||
║ Документация: 3 полных документа ║
|
|
||||||
║ Логирование: Полное покрытие ║
|
|
||||||
║ Совместимость: 100% (обратная) ║
|
|
||||||
╚════════════════════════════════════════════╝
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Следующие шаги
|
|
||||||
|
|
||||||
1. **Прочитайте** FINAL_FIX_SUMMARY.md (5 минут)
|
|
||||||
2. **Протестируйте** согласно ADJUSTMENT_VALUE_FIX_TESTING.md (10 минут)
|
|
||||||
3. **Проверьте логирование** в консоли браузера (2 минуты)
|
|
||||||
4. **Изучите** TECHNICAL_RACE_CONDITION_FIX.md если интересна теория (15 минут)
|
|
||||||
5. **Используйте** исправленную функциональность в production
|
|
||||||
|
|
||||||
🎉 **Готово к использованию!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Документ создан: 2025-11-02*
|
|
||||||
*Последнее обновление: 2025-11-02*
|
|
||||||
*Версия: 1.0 Final*
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
# Техническое описание исправления Race Condition при загрузке значений корректировки цены
|
|
||||||
|
|
||||||
## Проблема: Race Condition
|
|
||||||
|
|
||||||
### Исходный код (проблематичный)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Строки 901-913 (загрузка значений)
|
|
||||||
if (currentAdjustmentType === 'increase_percent') {
|
|
||||||
increasePercentInput.value = currentAdjustmentValue; // ← Срабатывают события!
|
|
||||||
console.log('Loaded increase_percent:', currentAdjustmentValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строки 680-691 (event listener)
|
|
||||||
[increasePercentInput, increaseAmountInput, ...].forEach(input => {
|
|
||||||
input.addEventListener('input', () => { // ← Срабатывает при .value =
|
|
||||||
validateSingleAdjustment();
|
|
||||||
calculateFinalPrice(); // ← Это функция перезапишет скрытые поля!
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Строки 587-590 (в calculateFinalPrice)
|
|
||||||
if (!isInitializing) { // ← На этот момент isInitializing уже false!
|
|
||||||
adjustmentTypeInput.value = adjustmentType; // ← Перезаписано!
|
|
||||||
adjustmentValueInput.value = adjustmentValue; // ← Потеряны загруженные значения!
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Последовательность выполнения (БАГ)
|
|
||||||
|
|
||||||
```
|
|
||||||
Момент 1: setTimeout(async () => { ... }, 500)
|
|
||||||
↓
|
|
||||||
Момент 2: increasePercentInput.value = 10 // Установка значения
|
|
||||||
↓
|
|
||||||
Момент 3: ✨ Браузер автоматически срабатывает событие 'input'
|
|
||||||
↓
|
|
||||||
Момент 4: input.addEventListener('input', () => {
|
|
||||||
validateSingleAdjustment(); // OK
|
|
||||||
calculateFinalPrice(); // ← ВЫЗОВ ФУНКЦИИ
|
|
||||||
})
|
|
||||||
↓
|
|
||||||
Момент 5: Внутри calculateFinalPrice():
|
|
||||||
// isInitializing = false (установлено в строке 923)
|
|
||||||
if (!isInitializing) { // ← true! Условие выполняется
|
|
||||||
adjustmentTypeInput.value = 'none'; // ← БАГ: перезаписано!
|
|
||||||
adjustmentValueInput.value = 0; // ← БАГ: потеряно значение!
|
|
||||||
}
|
|
||||||
↓
|
|
||||||
Момент 6: validateSingleAdjustment() вызвана с пустыми значениями
|
|
||||||
↓
|
|
||||||
Момент 7: UI показывает пустые поля ❌
|
|
||||||
```
|
|
||||||
|
|
||||||
### Почему это происходит нерегулярно (1 из 10)?
|
|
||||||
|
|
||||||
1. **Timing зависит от нескольких факторов:**
|
|
||||||
- Скорость браузера
|
|
||||||
- Загруженность CPU
|
|
||||||
- Количество товаров в комплекте
|
|
||||||
- Скорость сети (если AJAX запросы)
|
|
||||||
|
|
||||||
2. **Иногда события срабатывают быстро, иногда медленно:**
|
|
||||||
- Если `input` событие срабатывает ДО строки 923 (`isInitializing = false`), то всё OK
|
|
||||||
- Если `input` событие срабатывает ПОСЛЕ строки 923, то значения перезаписываются
|
|
||||||
|
|
||||||
3. **Это классическая race condition:**
|
|
||||||
```
|
|
||||||
Thread 1 (setTimeout): Thread 2 (event listener):
|
|
||||||
1. input.value = 10
|
|
||||||
2. ✨ input event fired!
|
|
||||||
3. 4. calculateFinalPrice() called
|
|
||||||
4. isInitializing = false 5. input.value = '' (перезаписано!)
|
|
||||||
5. console.log(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Решение: Трёхуровневая защита
|
|
||||||
|
|
||||||
### Уровень 1: Подавление событий во время загрузки
|
|
||||||
|
|
||||||
**Идея:** Запретить event listeners обрабатывать события во время загрузки значений
|
|
||||||
|
|
||||||
**Код:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Строка 435: Добавлен новый флаг
|
|
||||||
let isLoadingAdjustmentValues = false;
|
|
||||||
|
|
||||||
// Строки 683-696: Проверка в event listener
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
// ← Пропускаем обработку ПЕРЕД вызовом функции
|
|
||||||
if (isLoadingAdjustmentValues) {
|
|
||||||
console.log('Skipping event during adjustment value loading');
|
|
||||||
return; // ← ВЫХОД! validateSingleAdjustment и calculateFinalPrice НЕ вызываются
|
|
||||||
}
|
|
||||||
validateSingleAdjustment();
|
|
||||||
calculateFinalPrice();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Как это работает:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Момент 1: setTimeout () => { isLoadingAdjustmentValues = true; }
|
|
||||||
↓
|
|
||||||
Момент 2: increasePercentInput.value = 10
|
|
||||||
↓
|
|
||||||
Момент 3: ✨ Браузер срабатывает событие 'input'
|
|
||||||
↓
|
|
||||||
Момент 4: input.addEventListener('input', () => {
|
|
||||||
if (isLoadingAdjustmentValues) { // ← TRUE!
|
|
||||||
console.log('Skipping event...');
|
|
||||||
return; // ← ВЫХОД, БЕЗ calculateFinalPrice!
|
|
||||||
}
|
|
||||||
})
|
|
||||||
↓
|
|
||||||
Момент 5: validateSingleAdjustment() и calculateFinalPrice() НЕ вызываются ✅
|
|
||||||
↓
|
|
||||||
Момент 6: isLoadingAdjustmentValues = false;
|
|
||||||
↓
|
|
||||||
Момент 7: validateSingleAdjustment() вызывается вручную (строка 931) ✅
|
|
||||||
↓
|
|
||||||
Момент 8: calculateFinalPrice() вызывается вручную с isInitializing = true ✅
|
|
||||||
```
|
|
||||||
|
|
||||||
**Преимущества:**
|
|
||||||
- ✅ Просто и понятно
|
|
||||||
- ✅ Полностью подавляет нежелательные вызовы
|
|
||||||
- ✅ Логирует что происходит ("Skipping event...")
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Уровень 2: Защита скрытых полей от перезаписи
|
|
||||||
|
|
||||||
**Идея:** Даже если calculateFinalPrice() будет вызвана, она не перезапишет скрытые поля
|
|
||||||
|
|
||||||
**Код:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Строка 434: Флаг инициализации
|
|
||||||
let isInitializing = true;
|
|
||||||
|
|
||||||
// Строки 587-590: Проверка перед обновлением скрытых полей
|
|
||||||
if (!isInitializing) {
|
|
||||||
adjustmentTypeInput.value = adjustmentType;
|
|
||||||
adjustmentValueInput.value = adjustmentValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строки 943-947: Завершение инициализации
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
isInitializing = false; // ← Только после всех событий
|
|
||||||
console.log('Initialization complete, isInitializing =', isInitializing);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Дополнительная защита:**
|
|
||||||
- Даже если первый уровень защиты (подавление событий) не сработает
|
|
||||||
- Второй уровень гарантирует что скрытые поля не будут перезаписаны
|
|
||||||
- Это "fail-safe" механизм
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Уровень 3: Синхронизация с браузером через requestAnimationFrame
|
|
||||||
|
|
||||||
**Идея:** Убедиться что `isInitializing = false` устанавливается в конце frame cycle
|
|
||||||
|
|
||||||
**Код:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Вместо простого: isInitializing = false;
|
|
||||||
// Используем два вложенных requestAnimationFrame:
|
|
||||||
|
|
||||||
requestAnimationFrame(() => { // ← Frame 1: задача добавлена в очередь
|
|
||||||
requestAnimationFrame(() => { // ← Frame 2: гарантирует выполнение после первого рендера
|
|
||||||
isInitializing = false; // ← Устанавливается в конце frame cycle
|
|
||||||
console.log('Initialization complete, isInitializing =', isInitializing);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Что это даёт:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Браузерный event loop:
|
|
||||||
[
|
|
||||||
setTimeoutCallback 500ms ← calculateFinalPrice вызвана с isInitializing = true
|
|
||||||
input event ← Если срабатит, то calculateFinalPrice не перезапишет скрытые поля
|
|
||||||
change event ← Если срабатит, то calculateFinalPrice не перезапишет скрытые поля
|
|
||||||
requestAnimationFrame 1 ← добавлен в очередь
|
|
||||||
requestAnimationFrame 2 ← выполнится, устанавливает isInitializing = false
|
|
||||||
[РЕНДЕР]
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Гарантии:**
|
|
||||||
- ✅ isInitializing = false устанавливается ПОСЛЕ всех событий
|
|
||||||
- ✅ ПОСЛЕ всех вызовов calculateFinalPrice которые могли быть в тормозе
|
|
||||||
- ✅ Не полагается на setTimeout с угадыванием времени
|
|
||||||
- ✅ Синхронизировано с браузерным rendering cycle
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Полный поток выполнения (с исправлением)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 1. DOMContentLoaded
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
|
|
||||||
// 2. Инициализация флагов
|
|
||||||
let isInitializing = true;
|
|
||||||
let isLoadingAdjustmentValues = false;
|
|
||||||
|
|
||||||
// 3. Регистрация event listeners (с проверкой флагов)
|
|
||||||
[increasePercentInput, ...].forEach(input => {
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
if (isLoadingAdjustmentValues) return; // ← Уровень 1 защиты
|
|
||||||
validateSingleAdjustment();
|
|
||||||
calculateFinalPrice();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. calculateFinalPrice с защитой скрытых полей
|
|
||||||
async function calculateFinalPrice() {
|
|
||||||
// ... вычисления ...
|
|
||||||
if (!isInitializing) { // ← Уровень 2 защиты
|
|
||||||
adjustmentTypeInput.value = adjustmentType;
|
|
||||||
adjustmentValueInput.value = adjustmentValue;
|
|
||||||
}
|
|
||||||
// ... остальное ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. setTimeout 500ms - загрузка сохранённых значений
|
|
||||||
setTimeout(async () => {
|
|
||||||
|
|
||||||
// 5a. Включаем подавление событий (Уровень 1)
|
|
||||||
isLoadingAdjustmentValues = true;
|
|
||||||
|
|
||||||
// 5b. Загружаем значения (события подавляются)
|
|
||||||
increasePercentInput.value = 10; // input event ПОДАВЛЕНА благодаря флагу
|
|
||||||
|
|
||||||
// 5c. Вызываем вручную (уже проверено что нет других событий)
|
|
||||||
validateSingleAdjustment();
|
|
||||||
|
|
||||||
// 5d. Отключаем подавление событий
|
|
||||||
isLoadingAdjustmentValues = false;
|
|
||||||
|
|
||||||
// 6. Пересчитываем цену (isInitializing = true, поэтому скрытые поля не перезапишутся)
|
|
||||||
await calculateFinalPrice();
|
|
||||||
|
|
||||||
// 7. Синхронизация с браузером (Уровень 3)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// 8. Инициализация завершена - теперь события могут обновлять скрытые поля
|
|
||||||
isInitializing = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сравнение подходов
|
|
||||||
|
|
||||||
| Подход | Надёжность | Сложность | Решает проблему |
|
|
||||||
|--------|-----------|----------|-----------------|
|
|
||||||
| **Старый:** Просто setTimeout | 10% | Низкая | ❌ Нет |
|
|
||||||
| **Попытка 1:** Больше timeout | 50% | Низкая | ❌ Угадывание |
|
|
||||||
| **Попытка 2:** Object.defineProperty | 70% | Средняя | ❌ События всё равно срабатывают |
|
|
||||||
| **Решение:** Трёхуровневая защита | 99%+ | Средняя | ✅ Да |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Почему это работает
|
|
||||||
|
|
||||||
### Принцип 1: Explicit Event Suppression
|
|
||||||
Вместо угадывания timing'а, явно запрещаем события срабатывать
|
|
||||||
|
|
||||||
### Принцип 2: Defense in Depth
|
|
||||||
Если один уровень защиты не сработает, другой подстраховывает
|
|
||||||
|
|
||||||
### Принцип 3: Browser Synchronization
|
|
||||||
Используем браузерные APIs (requestAnimationFrame) вместо угадывания setTimeout
|
|
||||||
|
|
||||||
### Принцип 4: Logging & Debugging
|
|
||||||
Каждый уровень логирует что происходит, помогает отладке
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Результаты
|
|
||||||
|
|
||||||
**До исправления:**
|
|
||||||
- 1/10 раз: значение отображается ✅
|
|
||||||
- 9/10 раз: значение не отображается ❌
|
|
||||||
|
|
||||||
**После исправления:**
|
|
||||||
- 10/10 раз: значение отображается ✅
|
|
||||||
- Консоль показывает правильный порядок выполнения ✅
|
|
||||||
- Логирование помогает отладке ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Чему можно научиться из этой ошибки
|
|
||||||
|
|
||||||
1. **Race Conditions нелегко поймать** - они проявляются непредсказуемо
|
|
||||||
2. **setTimeout плохая синхронизация** - используйте requestAnimationFrame
|
|
||||||
3. **Event listeners могут срабатывать неожиданно** - нужны флаги подавления
|
|
||||||
4. **Логирование спасает** - помогает понять порядок выполнения
|
|
||||||
5. **Defense in Depth работает** - несколько уровней защиты лучше чем один
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Смежные темы
|
|
||||||
|
|
||||||
### Другие способы синхронизации в JS
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 1. setTimeout (плохо - угадывание)
|
|
||||||
setTimeout(() => { isInitializing = false; }, 100);
|
|
||||||
|
|
||||||
// 2. requestAnimationFrame (хорошо - синхронизация с браузером)
|
|
||||||
requestAnimationFrame(() => { isInitializing = false; });
|
|
||||||
|
|
||||||
// 3. MutationObserver (очень хорошо - для DOM changes)
|
|
||||||
new MutationObserver(() => { isInitializing = false; }).observe(element, {attributes: true});
|
|
||||||
|
|
||||||
// 4. Promise.resolve() (хорошо - микротаска)
|
|
||||||
Promise.resolve().then(() => { isInitializing = false; });
|
|
||||||
|
|
||||||
// 5. Событие завершения (лучше всего - если доступно)
|
|
||||||
element.addEventListener('loaded', () => { isInitializing = false; });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Как правильно работать с input events
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ❌ Плохо: установка .value срабатит событие
|
|
||||||
element.value = 'new value'; // input event срабатит
|
|
||||||
|
|
||||||
// ✅ Хорошо 1: подавить событие флагом
|
|
||||||
isLoadingValue = true;
|
|
||||||
element.value = 'new value'; // событие срабатит но обработчик проверит флаг
|
|
||||||
isLoadingValue = false;
|
|
||||||
|
|
||||||
// ✅ Хорошо 2: использовать API для установки без события
|
|
||||||
// К сожалению, для input нет такого API
|
|
||||||
|
|
||||||
// ✅ Хорошо 3: использовать Object.defineProperty (но сложно)
|
|
||||||
Object.defineProperty(element, 'value', { value: 'new', configurable: true });
|
|
||||||
|
|
||||||
// ✅ Хорошо 4: вручную вызвать нужные обработчики
|
|
||||||
element.value = 'new value';
|
|
||||||
// Вызываем вручную то что нужно, пропускаем что не нужно
|
|
||||||
validateSingleAdjustment();
|
|
||||||
// calculateFinalPrice() НЕ вызываем, потому что isInitializing = true
|
|
||||||
```
|
|
||||||
|
|
||||||
🎓 **Практический пример применения продвинутых JS техник в реальном проекте**
|
|
||||||
210
TESTING_GUIDE.md
210
TESTING_GUIDE.md
@@ -1,210 +0,0 @@
|
|||||||
# ConfigurableKitProduct Testing Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
The M2M architecture for variable products is now fully implemented. This guide walks through testing the complete workflow.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- Django project is running on `http://grach.localhost:8000/`
|
|
||||||
- You have at least 2-3 ProductKit objects in the database
|
|
||||||
- Admin panel is accessible
|
|
||||||
|
|
||||||
## Automated Tests
|
|
||||||
|
|
||||||
Run the test scripts to verify implementation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd myproject
|
|
||||||
|
|
||||||
# Test 1: Basic model and form verification
|
|
||||||
python test_configurable_simple.py
|
|
||||||
|
|
||||||
# Test 2: Complete workflow test
|
|
||||||
python test_workflow.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output: "OK: ALL TESTS PASSED!"
|
|
||||||
|
|
||||||
## Manual Testing - Full Workflow
|
|
||||||
|
|
||||||
### Step 1: Create a Variable Product
|
|
||||||
|
|
||||||
1. Open http://grach.localhost:8000/products/configurable-kits/create/
|
|
||||||
2. Fill in the form:
|
|
||||||
- **Name**: "Test Bouquet"
|
|
||||||
- **SKU**: "TEST-BQ-001"
|
|
||||||
- **Description**: "A test variable product"
|
|
||||||
|
|
||||||
### Step 2: Define Attributes
|
|
||||||
|
|
||||||
In the "Attributes" section, add attribute values:
|
|
||||||
|
|
||||||
1. **First Attribute Group** - "Length" (Длина)
|
|
||||||
- Click "Add Attribute"
|
|
||||||
- Name: Длина
|
|
||||||
- Value: 50
|
|
||||||
- Position: 0
|
|
||||||
- Click "Add Attribute" again
|
|
||||||
- Name: Длина
|
|
||||||
- Value: 60
|
|
||||||
- Position: 1
|
|
||||||
- Click "Add Attribute" again
|
|
||||||
- Name: Длина
|
|
||||||
- Value: 70
|
|
||||||
- Position: 2
|
|
||||||
|
|
||||||
2. **Second Attribute Group** - "Packaging" (Упаковка)
|
|
||||||
- Click "Add Attribute"
|
|
||||||
- Name: Упаковка
|
|
||||||
- Value: БЕЗ
|
|
||||||
- Position: 0
|
|
||||||
- Click "Add Attribute" again
|
|
||||||
- Name: Упаковка
|
|
||||||
- Value: В УПАКОВКЕ
|
|
||||||
- Position: 1
|
|
||||||
|
|
||||||
### Step 3: Create Variants
|
|
||||||
|
|
||||||
In the "Variants" section, create variants by:
|
|
||||||
|
|
||||||
1. **Variant 1** - Default variant
|
|
||||||
- Select a ProductKit (e.g., "Kit 1")
|
|
||||||
- Select attributes:
|
|
||||||
- Длина: 50
|
|
||||||
- Упаковка: БЕЗ
|
|
||||||
- Check "По умолчанию" (Default)
|
|
||||||
|
|
||||||
2. **Variant 2** - Alternative
|
|
||||||
- Click "Add Variant"
|
|
||||||
- Select a different ProductKit (e.g., "Kit 2")
|
|
||||||
- Select attributes:
|
|
||||||
- Длина: 60
|
|
||||||
- Упаковка: В УПАКОВКЕ
|
|
||||||
- Don't check default
|
|
||||||
|
|
||||||
3. **Variant 3** - Another alternative
|
|
||||||
- Click "Add Variant"
|
|
||||||
- Select yet another ProductKit (e.g., "Kit 3")
|
|
||||||
- Select attributes:
|
|
||||||
- Длина: 70
|
|
||||||
- Упаковка: БЕЗ
|
|
||||||
- Don't check default
|
|
||||||
|
|
||||||
### Step 4: Save and Verify
|
|
||||||
|
|
||||||
1. Click "Save"
|
|
||||||
2. If successful, you should see the product in the list
|
|
||||||
3. Click on it to edit and verify:
|
|
||||||
- All attributes are saved correctly
|
|
||||||
- All variants have their correct attribute values
|
|
||||||
- The default variant is marked correctly
|
|
||||||
|
|
||||||
## Testing Validation
|
|
||||||
|
|
||||||
### Test 1: Missing Attribute Validation
|
|
||||||
|
|
||||||
1. Edit the product you just created
|
|
||||||
2. Add a new variant
|
|
||||||
3. Select a ProductKit but leave one of the attribute dropdowns empty
|
|
||||||
4. Click Save
|
|
||||||
5. **Expected**: Form should show error: "Вариант X: необходимо заполнить атрибут(ы) 'Длина'."
|
|
||||||
|
|
||||||
### Test 2: Duplicate Kit Validation
|
|
||||||
|
|
||||||
1. Edit the product
|
|
||||||
2. Add a new variant with the same ProductKit as Variant 1
|
|
||||||
3. Click Save
|
|
||||||
4. **Expected**: Form should show error: "Комплект 'X' добавлен более одного раза."
|
|
||||||
|
|
||||||
### Test 3: Multiple Default Validation
|
|
||||||
|
|
||||||
1. Edit the product
|
|
||||||
2. Check the "Default" checkbox on Variant 2
|
|
||||||
3. Don't uncheck Variant 1's default
|
|
||||||
4. Click Save
|
|
||||||
5. **Expected**: Form should show error: "Можно установить только один вариант как 'по умолчанию'."
|
|
||||||
|
|
||||||
### Test 4: Dynamic Variant Addition
|
|
||||||
|
|
||||||
1. Click "Add Variant" button
|
|
||||||
2. A new form row should appear with:
|
|
||||||
- Kit dropdown
|
|
||||||
- All attribute dropdowns matching the first variant
|
|
||||||
- Default checkbox
|
|
||||||
- Delete button
|
|
||||||
3. **Expected**: All fields should be properly named with correct formset indices
|
|
||||||
|
|
||||||
## Database Verification
|
|
||||||
|
|
||||||
### Check M2M Relationships
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django_tenants.utils import tenant_context
|
|
||||||
from tenants.models import Client
|
|
||||||
from products.models.kits import ConfigurableKitProduct, ConfigurableKitOptionAttribute
|
|
||||||
|
|
||||||
client = Client.objects.get(schema_name='grach')
|
|
||||||
|
|
||||||
with tenant_context(client):
|
|
||||||
# Get your test product
|
|
||||||
product = ConfigurableKitProduct.objects.get(name='Test Bouquet')
|
|
||||||
|
|
||||||
# Check attributes
|
|
||||||
attrs = product.parent_attributes.all()
|
|
||||||
print(f"Attributes: {attrs.count()}")
|
|
||||||
for attr in attrs:
|
|
||||||
print(f" - {attr.name} = {attr.option}")
|
|
||||||
|
|
||||||
# Check variants and their attributes
|
|
||||||
for option in product.options.all():
|
|
||||||
print(f"\nVariant for kit {option.kit.name}:")
|
|
||||||
for opt_attr in option.attributes_set.all():
|
|
||||||
print(f" - {opt_attr.attribute.name} = {opt_attr.attribute.option}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## What to Check
|
|
||||||
|
|
||||||
- [ ] Product created successfully
|
|
||||||
- [ ] Attributes display in correct order
|
|
||||||
- [ ] Variants can be created with all required attributes
|
|
||||||
- [ ] Form validates missing attributes
|
|
||||||
- [ ] Form prevents duplicate kits
|
|
||||||
- [ ] Form prevents multiple default variants
|
|
||||||
- [ ] Dynamic variant addition works with all attribute fields
|
|
||||||
- [ ] Delete button removes variants correctly
|
|
||||||
- [ ] Data persists correctly after save
|
|
||||||
- [ ] Editing existing product pre-fills attribute selections
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Template Error: "Unused 'attribute_' at end of if expression"
|
|
||||||
- **Fixed**: Changed `field.name.startswith 'attribute_'` to `"attribute_" in field.name`
|
|
||||||
- Already applied in the template
|
|
||||||
|
|
||||||
### Form Fields Not Showing for Attributes
|
|
||||||
- Check that parent product has attributes defined
|
|
||||||
- Verify `parent_attributes` are accessible in form __init__
|
|
||||||
- Check browser console for JavaScript errors
|
|
||||||
|
|
||||||
### M2M Relationships Not Saving
|
|
||||||
- Verify ConfigurableKitOptionAttribute model exists
|
|
||||||
- Check migration 0006 has been applied: `python manage.py migrate products`
|
|
||||||
- Verify view code properly creates ConfigurableKitOptionAttribute records
|
|
||||||
|
|
||||||
### Dynamic Variant Form Doesn't Show Attributes
|
|
||||||
- Check first form has attribute selects with `data-attribute-name` attribute
|
|
||||||
- Verify JavaScript addOptionBtn listener is working
|
|
||||||
- Check browser console for errors
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
- Attributes are indexed on option and attribute fields for fast queries
|
|
||||||
- Formset validation iterates through all forms and attributes
|
|
||||||
- For products with many attributes (>10), consider pagination
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
After successful testing, you can:
|
|
||||||
1. Delete test products and attributes
|
|
||||||
2. Create real variable products in admin
|
|
||||||
3. Test WooCommerce integration (if applicable)
|
|
||||||
4. Monitor performance with actual product data
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
# Реализация системы наличия товаров и цены вариантов
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Реализована система управления наличием товаров (Product) и вычисления цены для групп вариантов (ProductVariantGroup). Система работает на трёх уровнях:
|
|
||||||
|
|
||||||
1. **Product** — товар имеет поле `in_stock` (булево значение: есть/нет в наличии)
|
|
||||||
2. **ProductVariantGroup** — группа вариантов с вычисляемыми свойствами `in_stock` и `price`
|
|
||||||
3. **Stock** — система складских остатков определяет статус наличия на основе `quantity_available > 0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Модель Product — добавлено поле `in_stock`
|
|
||||||
|
|
||||||
### Изменение в `/products/models.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Product(models.Model):
|
|
||||||
# ... другие поля ...
|
|
||||||
in_stock = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="В наличии",
|
|
||||||
db_index=True,
|
|
||||||
help_text="Автоматически обновляется при изменении остатков на складе"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Миграция**: `products/migrations/0003_add_product_in_stock.py`
|
|
||||||
|
|
||||||
### Особенности:
|
|
||||||
- Поле хранится в БД (для оптимизации поиска и фильтрации)
|
|
||||||
- Индексировано для быстрого поиска товаров в наличии
|
|
||||||
- Обновляется **автоматически** при изменении остатков через сигналы
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Сигналы для автоматического обновления `Product.in_stock`
|
|
||||||
|
|
||||||
### Изменения в `/inventory/signals.py`:
|
|
||||||
|
|
||||||
Добавлены два сигнала:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@receiver(post_save, sender=Stock)
|
|
||||||
def update_product_in_stock_on_stock_change(sender, instance, created, **kwargs):
|
|
||||||
"""
|
|
||||||
При создании/изменении Stock записи обновляем Product.in_stock.
|
|
||||||
"""
|
|
||||||
_update_product_in_stock(instance.product_id)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Stock)
|
|
||||||
def update_product_in_stock_on_stock_delete(sender, instance, **kwargs):
|
|
||||||
"""
|
|
||||||
При удалении Stock записи обновляем Product.in_stock.
|
|
||||||
"""
|
|
||||||
_update_product_in_stock(instance.product_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Вспомогательная функция:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _update_product_in_stock(product_id):
|
|
||||||
"""
|
|
||||||
Обновить статус in_stock на основе остатков.
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
- Товар в наличии (in_stock=True) если существует хотя бы один Stock
|
|
||||||
с quantity_available > 0 (есть свободный остаток на любом складе)
|
|
||||||
- Товар не в наличии (in_stock=False) если нет ни одного Stock с остатком
|
|
||||||
"""
|
|
||||||
product = Product.objects.get(id=product_id)
|
|
||||||
|
|
||||||
has_stock = Stock.objects.filter(
|
|
||||||
product=product,
|
|
||||||
quantity_available__gt=0 # Свободный остаток > 0
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
if product.in_stock != has_stock:
|
|
||||||
Product.objects.filter(id=product.id).update(in_stock=has_stock)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Как это работает:
|
|
||||||
|
|
||||||
1. **При создании приходного документа (Incoming)**:
|
|
||||||
- Создаётся StockBatch (партия)
|
|
||||||
- Создаётся/обновляется Stock (агрегированный остаток)
|
|
||||||
- Stock.refresh_from_batches() пересчитывает quantity_available
|
|
||||||
- Срабатывает сигнал post_save на Stock
|
|
||||||
- Product.in_stock автоматически обновляется
|
|
||||||
|
|
||||||
2. **При продаже (Sale)**:
|
|
||||||
- StockBatchManager.write_off_by_fifo() списывает товар
|
|
||||||
- Stock.quantity_available уменьшается
|
|
||||||
- Срабатывает сигнал post_save на Stock
|
|
||||||
- Product.in_stock автоматически обновляется
|
|
||||||
|
|
||||||
3. **При списании (WriteOff)**:
|
|
||||||
- WriteOff модель уменьшает quantity в StockBatch
|
|
||||||
- Stock.refresh_from_batches() пересчитывает остаток
|
|
||||||
- Срабатывает сигнал post_save на Stock
|
|
||||||
- Product.in_stock автоматически обновляется
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Модель ProductVariantGroup — свойства `in_stock` и `price`
|
|
||||||
|
|
||||||
### Изменения в `/products/models.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ProductVariantGroup(models.Model):
|
|
||||||
# ... существующие поля ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def in_stock(self):
|
|
||||||
"""
|
|
||||||
Вариант в наличии, если хотя бы один из его товаров в наличии.
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
- Проверяет есть ли товар с Product.in_stock=True в этой группе
|
|
||||||
- Возвращает True/False
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
- "Роза 50см" в наличии → вариант в наличии
|
|
||||||
- "Роза 60см" нет, но "Роза 70см" есть → вариант в наличии
|
|
||||||
- Все розы отсутствуют → вариант не в наличии
|
|
||||||
"""
|
|
||||||
return self.items.filter(product__in_stock=True).exists()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def price(self):
|
|
||||||
"""
|
|
||||||
Цена варианта определяется по приоритету товаров.
|
|
||||||
|
|
||||||
Логика:
|
|
||||||
1. Идём по товарам в порядке приоритета (priority = 1, 2, 3...)
|
|
||||||
2. Первый товар в наличии (in_stock=True) → берём его цену
|
|
||||||
3. Если ни один товар не в наличии → берём максимальную цену из всех товаров
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
- Приоритет 1 (роза 50см) в наличии: цена 50.00 руб
|
|
||||||
- Приоритет 1 нет, приоритет 2 (роза 60см) в наличии: цена 60.00 руб
|
|
||||||
- Все недоступны: цена = max(50.00, 60.00, 70.00) = 70.00 руб
|
|
||||||
|
|
||||||
Возвращает Decimal (цену) или None если группа пуста.
|
|
||||||
"""
|
|
||||||
items = self.items.all().order_by('priority', 'id')
|
|
||||||
|
|
||||||
if not items.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Ищем первый товар в наличии
|
|
||||||
for item in items:
|
|
||||||
if item.product.in_stock:
|
|
||||||
return item.product.sale_price
|
|
||||||
|
|
||||||
# Если ни один товар не в наличии - берём самый дорогой
|
|
||||||
max_price = None
|
|
||||||
for item in items:
|
|
||||||
if max_price is None or item.product.sale_price > max_price:
|
|
||||||
max_price = item.product.sale_price
|
|
||||||
|
|
||||||
return max_price
|
|
||||||
```
|
|
||||||
|
|
||||||
### Использование в шаблонах и views:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# В view
|
|
||||||
variant_group = ProductVariantGroup.objects.get(id=1)
|
|
||||||
|
|
||||||
# Проверить есть ли вариант в наличии
|
|
||||||
if variant_group.in_stock:
|
|
||||||
# Вариант доступен
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Получить цену варианта
|
|
||||||
price = variant_group.price # Decimal('50.00')
|
|
||||||
|
|
||||||
# В шаблоне
|
|
||||||
{{ variant_group.in_stock }} <!-- True/False -->
|
|
||||||
{{ variant_group.price }} <!-- 50.00 -->
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Архитектурные решения
|
|
||||||
|
|
||||||
### Почему свойства (properties) а не поля БД?
|
|
||||||
|
|
||||||
**ProductVariantGroup.in_stock** и **ProductVariantGroup.price** реализованы как **свойства (properties)**, а не как сохраняемые поля:
|
|
||||||
|
|
||||||
✅ **Преимущества:**
|
|
||||||
- **Всегда актуальны** — в любой момент рассчитываются на основе текущих данных
|
|
||||||
- **Нет дублирования данных** — источник истины один (Product.in_stock и Product.sale_price)
|
|
||||||
- **Без миграций** — при изменении логики не нужны миграции БД
|
|
||||||
- **Простота** — чистый и понятный код
|
|
||||||
|
|
||||||
⚠️ **Недостатки (решены):**
|
|
||||||
- **Производительность** — O(N) на каждый вызов, где N = количество товаров в группе
|
|
||||||
- **Решение**: используйте prefetch_related в views:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Плохо (N+1 queries)
|
|
||||||
for variant_group in groups:
|
|
||||||
print(variant_group.price)
|
|
||||||
|
|
||||||
# Хорошо (1 query + 1 query для товаров)
|
|
||||||
groups = ProductVariantGroup.objects.prefetch_related('items__product')
|
|
||||||
for variant_group in groups:
|
|
||||||
print(variant_group.price)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Почему Product.in_stock = поле БД?
|
|
||||||
|
|
||||||
**Product.in_stock** — это сохраняемое поле в БД:
|
|
||||||
|
|
||||||
✅ **Причины:**
|
|
||||||
- **Оптимизация поиска** — можно фильтровать: `Product.objects.filter(in_stock=True)`
|
|
||||||
- **Производительность** — не нужно JOIN'ить Stock при поиске
|
|
||||||
- **Индекс** — ускоряет фильтрацию
|
|
||||||
- **Системная важность** — наличие товара — критичный параметр
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Поток данных (Data Flow)
|
|
||||||
|
|
||||||
```
|
|
||||||
Incoming (приход товара)
|
|
||||||
↓
|
|
||||||
StockBatch создаётся
|
|
||||||
↓
|
|
||||||
Stock создаётся/обновляется
|
|
||||||
├─ quantity_available пересчитывается
|
|
||||||
└─ post_save сигнал срабатывает
|
|
||||||
↓
|
|
||||||
_update_product_in_stock(product_id)
|
|
||||||
├─ Проверяет есть ли Stock с quantity_available > 0
|
|
||||||
└─ Product.in_stock обновляется (True/False)
|
|
||||||
↓
|
|
||||||
ProductVariantGroup.in_stock (свойство)
|
|
||||||
├─ Проверяет есть ли товар в группе с Product.in_stock=True
|
|
||||||
└─ Возвращает True/False
|
|
||||||
|
|
||||||
ProductVariantGroup.price (свойство)
|
|
||||||
├─ Идёт по товарам по приоритету
|
|
||||||
├─ Берёт цену первого в наличии
|
|
||||||
└─ Или максимальную цену если никто не в наличии
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Примеры использования
|
|
||||||
|
|
||||||
### Пример 1: Проверить есть ли товар в наличии
|
|
||||||
|
|
||||||
```python
|
|
||||||
from products.models import Product
|
|
||||||
|
|
||||||
# Получить товар
|
|
||||||
product = Product.objects.get(id=1)
|
|
||||||
|
|
||||||
# Проверить наличие
|
|
||||||
if product.in_stock:
|
|
||||||
print(f"{product.name} в наличии")
|
|
||||||
else:
|
|
||||||
print(f"{product.name} не в наличии")
|
|
||||||
|
|
||||||
# Фильтровать товары в наличии
|
|
||||||
in_stock_products = Product.objects.filter(in_stock=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример 2: Работа с группой вариантов
|
|
||||||
|
|
||||||
```python
|
|
||||||
from products.models import ProductVariantGroup
|
|
||||||
|
|
||||||
# Получить группу
|
|
||||||
group = ProductVariantGroup.objects.prefetch_related('items__product').get(id=1)
|
|
||||||
|
|
||||||
# Проверить статус группы
|
|
||||||
print(f"Вариант в наличии: {group.in_stock}") # True/False
|
|
||||||
print(f"Цена варианта: {group.price} руб") # Decimal('50.00')
|
|
||||||
|
|
||||||
# Получить всю информацию
|
|
||||||
for item in group.items.all().order_by('priority'):
|
|
||||||
status = "✓" if item.product.in_stock else "✗"
|
|
||||||
print(f"{item.priority}. {item.product.name} ({item.product.sale_price}) {status}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример 3: Отображение в шаблоне
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% for variant_group in variant_groups %}
|
|
||||||
<div class="variant-group">
|
|
||||||
<h3>{{ variant_group.name }}</h3>
|
|
||||||
|
|
||||||
{% if variant_group.in_stock %}
|
|
||||||
<span class="badge badge-success">В наличии</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-danger">Нет в наличии</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="price">
|
|
||||||
Цена: {{ variant_group.price }} руб
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="variants">
|
|
||||||
{% for item in variant_group.items.all %}
|
|
||||||
<li>
|
|
||||||
{{ item.product.name }}
|
|
||||||
{% if item.product.in_stock %}
|
|
||||||
<span class="in-stock">✓ В наличии</span>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Тестирование
|
|
||||||
|
|
||||||
### Создан тестовый скрипт: `test_variant_stock.py`
|
|
||||||
|
|
||||||
Скрипт проверяет:
|
|
||||||
|
|
||||||
1. **ТЕСТ 1**: Обновление Product.in_stock при создании Stock
|
|
||||||
- Создаёт товар без наличия (in_stock=False)
|
|
||||||
- Добавляет приход товара (создаёт Stock)
|
|
||||||
- Проверяет что Product.in_stock автоматически стал True
|
|
||||||
|
|
||||||
2. **ТЕСТ 2**: Свойство ProductVariantGroup.in_stock
|
|
||||||
- Создаёт группу вариантов с несколькими товарами
|
|
||||||
- Один товар в наличии
|
|
||||||
- Проверяет что вариант.in_stock = True
|
|
||||||
|
|
||||||
3. **ТЕСТ 3**: Свойство ProductVariantGroup.price
|
|
||||||
- Товары с приоритетами 1, 2, 3 и ценами 50, 60, 70 руб
|
|
||||||
- Только товар с приоритетом 1 в наличии
|
|
||||||
- Проверяет что вариант.price = 50.00 руб
|
|
||||||
|
|
||||||
4. **ТЕСТ 4**: Цена варианта когда ни один товар не в наличии
|
|
||||||
- Все товары не в наличии
|
|
||||||
- Цены: 100, 150, 200 руб
|
|
||||||
- Проверяет что вариант.price = 200.00 руб (максимальная)
|
|
||||||
|
|
||||||
### Запуск тестов:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Активировать окружение
|
|
||||||
source venv/Scripts/activate
|
|
||||||
|
|
||||||
# Запустить тестовый скрипт
|
|
||||||
python test_variant_stock.py
|
|
||||||
|
|
||||||
# Или запустить стандартные Django тесты
|
|
||||||
cd myproject
|
|
||||||
python manage.py test inventory -v 2
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Файлы которые были изменены/созданы
|
|
||||||
|
|
||||||
### Изменены:
|
|
||||||
|
|
||||||
1. **`myproject/products/models.py`**
|
|
||||||
- Добавлено поле `in_stock` в Product
|
|
||||||
- Добавлены свойства `in_stock` и `price` в ProductVariantGroup
|
|
||||||
- Добавлен индекс для `in_stock`
|
|
||||||
|
|
||||||
2. **`myproject/inventory/signals.py`**
|
|
||||||
- Добавлены импорты Stock в начало файла
|
|
||||||
- Добавлены два сигнала: `update_product_in_stock_on_stock_change` и `update_product_in_stock_on_stock_delete`
|
|
||||||
- Добавлена вспомогательная функция `_update_product_in_stock`
|
|
||||||
|
|
||||||
3. **`myproject/products/migrations/0003_add_product_in_stock.py`** (создана)
|
|
||||||
- Миграция для добавления поля `in_stock` в Product
|
|
||||||
|
|
||||||
### Созданы:
|
|
||||||
|
|
||||||
1. **`test_variant_stock.py`**
|
|
||||||
- Тестовый скрипт для проверки функциональности
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Резюме
|
|
||||||
|
|
||||||
✅ **Реализовано:**
|
|
||||||
|
|
||||||
1. **Product.in_stock** — булево поле, автоматически обновляется при изменении остатков
|
|
||||||
2. **ProductVariantGroup.in_stock** — свойство, вариант в наличии если хотя бы один товар в наличии
|
|
||||||
3. **ProductVariantGroup.price** — свойство, цена по приоритету или максимальная если все недоступны
|
|
||||||
4. **Сигналы** — автоматическое обновление Product.in_stock при изменении Stock
|
|
||||||
5. **Документация** — полное описание архитектуры и использования
|
|
||||||
|
|
||||||
✅ **Особенности:**
|
|
||||||
|
|
||||||
- Система простая и элегантная (без костылей)
|
|
||||||
- Обратная совместимость не требуется (как вы просили)
|
|
||||||
- Высокая производительность (индексирование, минимум JOIN'ов)
|
|
||||||
- Актуальные данные (сигналы гарантируют синхронизацию)
|
|
||||||
- Легко расширяемая (свойства можно менять без миграций)
|
|
||||||
|
|
||||||
✅ **Готово к использованию в views и шаблонах!**
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# Быстрый гид: Динамическая себестоимость товаров
|
|
||||||
|
|
||||||
## Как это работает
|
|
||||||
|
|
||||||
Себестоимость товара теперь **автоматически рассчитывается** на основе партий товара (StockBatch) по формуле средневзвешенной стоимости:
|
|
||||||
|
|
||||||
```
|
|
||||||
cost_price = Σ(количество × стоимость) / Σ(количество)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Автоматическое обновление
|
|
||||||
|
|
||||||
Себестоимость обновляется **автоматически** при:
|
|
||||||
- ✅ Создании новой партии (поступление товара)
|
|
||||||
- ✅ Изменении количества в партии
|
|
||||||
- ✅ Изменении стоимости партии
|
|
||||||
- ✅ Удалении партии
|
|
||||||
|
|
||||||
**Никаких дополнительных действий не требуется!**
|
|
||||||
|
|
||||||
## Просмотр деталей
|
|
||||||
|
|
||||||
### На странице товара
|
|
||||||
|
|
||||||
1. Откройте страницу товара: `http://grach.localhost:8000/products/1/`
|
|
||||||
2. Найдите строку "Себестоимость"
|
|
||||||
3. Нажмите кнопку **"Детали расчета"**
|
|
||||||
4. Увидите:
|
|
||||||
- Кешированную стоимость (из БД)
|
|
||||||
- Рассчитанную стоимость (из партий)
|
|
||||||
- Таблицу с разбивкой по партиям
|
|
||||||
- Дату создания каждой партии
|
|
||||||
|
|
||||||
## Примеры сценариев
|
|
||||||
|
|
||||||
### Сценарий 1: Новый товар
|
|
||||||
```
|
|
||||||
Товар создан → cost_price = 0.00 (нет партий)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 2: Первая поставка
|
|
||||||
```
|
|
||||||
Поступление: 10 шт по 100 руб
|
|
||||||
→ Автоматически: cost_price = 100.00
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 3: Вторая поставка
|
|
||||||
```
|
|
||||||
Текущее: 10 шт по 100 руб (cost_price = 100.00)
|
|
||||||
Поступление: 10 шт по 120 руб
|
|
||||||
→ Автоматически: cost_price = 110.00
|
|
||||||
Расчет: (10×100 + 10×120) / 20 = 110.00
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 4: Товар закончился
|
|
||||||
```
|
|
||||||
Продажа: весь товар продан
|
|
||||||
→ Автоматически: cost_price = 0.00
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 5: Новая поставка после опустошения
|
|
||||||
```
|
|
||||||
Поступление: 15 шт по 130 руб
|
|
||||||
→ Автоматически: cost_price = 130.00
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ручной пересчет (если нужно)
|
|
||||||
|
|
||||||
Если по какой-то причине себестоимость "слетела", можно пересчитать вручную:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Пересчитать для тенанта grach
|
|
||||||
python manage.py recalculate_product_costs --schema=grach
|
|
||||||
|
|
||||||
# С подробным выводом
|
|
||||||
python manage.py recalculate_product_costs --schema=grach --verbose
|
|
||||||
|
|
||||||
# Предварительный просмотр без сохранения
|
|
||||||
python manage.py recalculate_product_costs --schema=grach --dry-run --verbose
|
|
||||||
|
|
||||||
# Показать только изменившиеся товары
|
|
||||||
python manage.py recalculate_product_costs --schema=grach --only-changed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Влияние на комплекты (ProductKit)
|
|
||||||
|
|
||||||
Стоимость комплектов теперь автоматически учитывает актуальную себестоимость компонентов!
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Раньше: использовалась статическая стоимость
|
|
||||||
# Теперь: использует динамическую стоимость из партий
|
|
||||||
kit_cost = sum(component.cost_price × quantity)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Проверка синхронизации
|
|
||||||
|
|
||||||
На странице товара в секции "Детали расчета":
|
|
||||||
- 🟢 **Зеленый статус** - все синхронизировано
|
|
||||||
- 🟡 **Желтый статус** - требуется синхронизация (запустите команду пересчета)
|
|
||||||
|
|
||||||
## API для разработчиков
|
|
||||||
|
|
||||||
### Получить детали расчета
|
|
||||||
|
|
||||||
```python
|
|
||||||
from products.models import Product
|
|
||||||
|
|
||||||
product = Product.objects.get(id=1)
|
|
||||||
|
|
||||||
# Получить детали
|
|
||||||
details = product.cost_price_details
|
|
||||||
|
|
||||||
print(f"Кешированная стоимость: {details['cached_cost']}")
|
|
||||||
print(f"Рассчитанная стоимость: {details['calculated_cost']}")
|
|
||||||
print(f"Синхронизировано: {details['is_synced']}")
|
|
||||||
print(f"Всего в партиях: {details['total_quantity']}")
|
|
||||||
|
|
||||||
# Перебрать партии
|
|
||||||
for batch in details['batches']:
|
|
||||||
print(f"Склад: {batch['warehouse_name']}")
|
|
||||||
print(f"Количество: {batch['quantity']}")
|
|
||||||
print(f"Стоимость: {batch['cost_price']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ручное обновление стоимости
|
|
||||||
|
|
||||||
```python
|
|
||||||
from products.services.cost_calculator import ProductCostCalculator
|
|
||||||
|
|
||||||
# Рассчитать новую стоимость
|
|
||||||
new_cost = ProductCostCalculator.calculate_weighted_average_cost(product)
|
|
||||||
|
|
||||||
# Обновить в БД
|
|
||||||
old_cost, new_cost, was_updated = ProductCostCalculator.update_product_cost(product)
|
|
||||||
|
|
||||||
if was_updated:
|
|
||||||
print(f"Стоимость обновлена: {old_cost} → {new_cost}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Логирование
|
|
||||||
|
|
||||||
Все операции логируются в стандартный Django logger:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger('products.services.cost_calculator')
|
|
||||||
```
|
|
||||||
|
|
||||||
Примеры сообщений:
|
|
||||||
- `INFO: Обновлена себестоимость товара SKU-001: 100.00 -> 110.00`
|
|
||||||
- `ERROR: Ошибка при расчете себестоимости для товара SKU-001: ...`
|
|
||||||
|
|
||||||
## Производительность
|
|
||||||
|
|
||||||
### Чтение cost_price
|
|
||||||
- **0 дополнительных запросов** - значение читается из БД
|
|
||||||
|
|
||||||
### Создание/изменение партии
|
|
||||||
- **1 дополнительный UPDATE** - автоматическое обновление cost_price
|
|
||||||
|
|
||||||
### Просмотр деталей (cost_price_details)
|
|
||||||
- **1 SELECT** - запрос партий товара
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: Нужно ли что-то делать после создания партии?**
|
|
||||||
A: Нет! Себестоимость обновляется автоматически через Django signals.
|
|
||||||
|
|
||||||
**Q: Что если у товара нет партий?**
|
|
||||||
A: cost_price = 0.00 (автоматически)
|
|
||||||
|
|
||||||
**Q: Можно ли вручную установить себестоимость?**
|
|
||||||
A: Можно, но при следующем изменении партий значение пересчитается автоматически.
|
|
||||||
|
|
||||||
**Q: Как проверить правильность расчета?**
|
|
||||||
A: Откройте "Детали расчета" на странице товара - там видна вся математика.
|
|
||||||
|
|
||||||
**Q: Влияет ли это на ProductKit?**
|
|
||||||
A: Да! Стоимость комплектов теперь использует актуальную себестоимость компонентов.
|
|
||||||
|
|
||||||
**Q: Что если синхронизация нарушилась?**
|
|
||||||
A: Запустите `python manage.py recalculate_product_costs --schema=grach`
|
|
||||||
|
|
||||||
## Техническая документация
|
|
||||||
|
|
||||||
Подробная техническая документация доступна в файле:
|
|
||||||
`DYNAMIC_COST_PRICE_IMPLEMENTATION.md`
|
|
||||||
|
|
||||||
## Контакты и поддержка
|
|
||||||
|
|
||||||
При возникновении проблем проверьте:
|
|
||||||
1. Логи Django (ошибки при расчете)
|
|
||||||
2. Страницу товара (секция "Детали расчета")
|
|
||||||
3. Запустите команду с --dry-run для проверки
|
|
||||||
|
|
||||||
---
|
|
||||||
Версия: 1.0
|
|
||||||
Дата: 2025-01-01
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
# Настройка Django Tenants для multi-tenancy
|
|
||||||
|
|
||||||
Этот проект настроен как SaaS-платформа с поддержкой multi-tenancy через django-tenants.
|
|
||||||
Каждый владелец магазина получает свой поддомен и изолированную схему БД в PostgreSQL.
|
|
||||||
|
|
||||||
## Шаг 1: Установка PostgreSQL
|
|
||||||
|
|
||||||
### Вариант A: Установка локально (Windows)
|
|
||||||
|
|
||||||
1. Скачайте PostgreSQL с https://www.postgresql.org/download/windows/
|
|
||||||
2. Установите PostgreSQL (запомните пароль для пользователя `postgres`)
|
|
||||||
3. Откройте pgAdmin или psql и создайте базу данных:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE DATABASE inventory_db;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Вариант B: Использование Docker (рекомендуется)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --name inventory-postgres \
|
|
||||||
-e POSTGRES_PASSWORD=postgres \
|
|
||||||
-e POSTGRES_DB=inventory_db \
|
|
||||||
-p 5432:5432 \
|
|
||||||
-d postgres:15
|
|
||||||
```
|
|
||||||
|
|
||||||
## Шаг 2: Установка зависимостей
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Это установит:
|
|
||||||
- `django-tenants==3.7.0`
|
|
||||||
- `psycopg2-binary==2.9.10`
|
|
||||||
- и другие зависимости
|
|
||||||
|
|
||||||
## Шаг 3: Настройка подключения к БД
|
|
||||||
|
|
||||||
Откройте `myproject/settings.py` и при необходимости измените параметры подключения:
|
|
||||||
|
|
||||||
```python
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django_tenants.postgresql_backend',
|
|
||||||
'NAME': 'inventory_db',
|
|
||||||
'USER': 'postgres',
|
|
||||||
'PASSWORD': 'postgres', # ВАШ ПАРОЛЬ
|
|
||||||
'HOST': 'localhost',
|
|
||||||
'PORT': '5432',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Шаг 4: Создание миграций
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Создать миграции для всех приложений
|
|
||||||
python manage.py makemigrations
|
|
||||||
|
|
||||||
# Применить миграции для public схемы
|
|
||||||
python manage.py migrate_schemas --shared
|
|
||||||
```
|
|
||||||
|
|
||||||
## Шаг 5: Создание публичного тенанта
|
|
||||||
|
|
||||||
Django-tenants требует создания public тенанта для работы главного домена (inventory.by):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tenants.models import Client, Domain
|
|
||||||
|
|
||||||
# Создать public тенанта
|
|
||||||
public_tenant = Client.objects.create(
|
|
||||||
schema_name='public',
|
|
||||||
name='Главный домен',
|
|
||||||
owner_email='admin@inventory.by',
|
|
||||||
owner_name='Администратор'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Создать домен для public
|
|
||||||
public_domain = Domain.objects.create(
|
|
||||||
domain='localhost', # Для локальной разработки
|
|
||||||
tenant=public_tenant,
|
|
||||||
is_primary=True
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f'Public тенант создан: {public_tenant}')
|
|
||||||
print(f'Public домен создан: {public_domain}')
|
|
||||||
exit()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Шаг 6: Создание суперпользователя
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Создать суперпользователя в public схеме
|
|
||||||
python manage.py createsuperuser --schema=public
|
|
||||||
```
|
|
||||||
|
|
||||||
Введите:
|
|
||||||
- Email: ваш email
|
|
||||||
- Name: ваше имя
|
|
||||||
- Password: ваш пароль
|
|
||||||
|
|
||||||
## Шаг 7: Создание тестового магазина (тенанта)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py create_tenant
|
|
||||||
```
|
|
||||||
|
|
||||||
Введите данные:
|
|
||||||
- Название магазина: Тестовый Магазин
|
|
||||||
- Схема БД: shop1
|
|
||||||
- Домен: shop1.localhost (или оставьте по умолчанию)
|
|
||||||
- Имя владельца: Иван Иванов
|
|
||||||
- Email: shop1@example.com
|
|
||||||
- Телефон: (опционально)
|
|
||||||
|
|
||||||
Команда автоматически:
|
|
||||||
1. Создаст тенанта в таблице `Client`
|
|
||||||
2. Создаст домен в таблице `Domain`
|
|
||||||
3. Создаст схему БД `shop1` в PostgreSQL
|
|
||||||
4. Применит все миграции к схеме `shop1`
|
|
||||||
|
|
||||||
## Шаг 8: Настройка hosts файла
|
|
||||||
|
|
||||||
Для локального тестирования добавьте в файл hosts:
|
|
||||||
|
|
||||||
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
|
|
||||||
**Linux/Mac**: `/etc/hosts`
|
|
||||||
|
|
||||||
```
|
|
||||||
127.0.0.1 localhost
|
|
||||||
127.0.0.1 shop1.localhost
|
|
||||||
127.0.0.1 shop2.localhost
|
|
||||||
```
|
|
||||||
|
|
||||||
## Шаг 9: Запуск сервера
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py runserver 0.0.0.0:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Шаг 10: Тестирование
|
|
||||||
|
|
||||||
### Доступ к админке супер-администратора (Public схема):
|
|
||||||
- URL: http://localhost:8000/admin/
|
|
||||||
- Логин: email и пароль суперпользователя
|
|
||||||
- Здесь вы можете управлять тенантами (магазинами)
|
|
||||||
|
|
||||||
### Доступ к админке магазина (Tenant схема):
|
|
||||||
- URL: http://shop1.localhost:8000/admin/
|
|
||||||
- Создайте суперпользователя для магазина:
|
|
||||||
```bash
|
|
||||||
python manage.py tenant_command createsuperuser --schema=shop1
|
|
||||||
```
|
|
||||||
- Здесь владелец магазина управляет своими товарами, заказами, клиентами
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Архитектура проекта
|
|
||||||
|
|
||||||
### Public Schema (схема `public`):
|
|
||||||
Доступна по адресу: `localhost` или `inventory.by`
|
|
||||||
|
|
||||||
**Модели:**
|
|
||||||
- `Client` - информация о тенантах (магазинах)
|
|
||||||
- `Domain` - домены тенантов
|
|
||||||
|
|
||||||
**Кто имеет доступ:**
|
|
||||||
- Супер-администратор (вы)
|
|
||||||
|
|
||||||
**Для чего:**
|
|
||||||
- Управление тенантами
|
|
||||||
- Просмотр статистики
|
|
||||||
- Биллинг (в будущем)
|
|
||||||
|
|
||||||
### Tenant Schema (схемы `shop1`, `shop2`, и т.д.):
|
|
||||||
Доступна по поддоменам: `shop1.localhost`, `shop2.localhost`
|
|
||||||
|
|
||||||
**Модели:**
|
|
||||||
- `Customer` - клиенты магазина
|
|
||||||
- `Address` - адреса клиентов
|
|
||||||
- `Shop` - точки магазина
|
|
||||||
- `Product`, `ProductKit`, `Category` - товары
|
|
||||||
- `Order`, `OrderItem` - заказы
|
|
||||||
- `Inventory` - складской учет
|
|
||||||
- `CustomUser` - сотрудники (для будущего)
|
|
||||||
|
|
||||||
**Кто имеет доступ:**
|
|
||||||
- Владелец магазина
|
|
||||||
- Сотрудники магазина (в будущем)
|
|
||||||
|
|
||||||
**Для чего:**
|
|
||||||
- Управление товарами
|
|
||||||
- Обработка заказов
|
|
||||||
- Работа с клиентами
|
|
||||||
- Складской учет
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Полезные команды
|
|
||||||
|
|
||||||
### Создать тенанта:
|
|
||||||
```bash
|
|
||||||
python manage.py create_tenant
|
|
||||||
```
|
|
||||||
|
|
||||||
### Применить миграции ко всем тенантам:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas
|
|
||||||
```
|
|
||||||
|
|
||||||
### Применить миграции только к public:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas --shared
|
|
||||||
```
|
|
||||||
|
|
||||||
### Применить миграции к конкретному тенанту:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas --schema=shop1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Выполнить команду для конкретного тенанта:
|
|
||||||
```bash
|
|
||||||
python manage.py tenant_command <command> --schema=shop1
|
|
||||||
```
|
|
||||||
|
|
||||||
Например:
|
|
||||||
```bash
|
|
||||||
python manage.py tenant_command createsuperuser --schema=shop1
|
|
||||||
python manage.py tenant_command loaddata data.json --schema=shop1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Список всех тенантов:
|
|
||||||
```bash
|
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
```python
|
|
||||||
from tenants.models import Client
|
|
||||||
for tenant in Client.objects.all():
|
|
||||||
print(f'{tenant.name}: {tenant.schema_name}')
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Устранение проблем
|
|
||||||
|
|
||||||
### Ошибка: "No tenant found for hostname"
|
|
||||||
- Проверьте, что домен добавлен в hosts файл
|
|
||||||
- Проверьте, что домен существует в таблице `Domain`
|
|
||||||
- Проверьте, что вы обращаетесь к правильному поддомену
|
|
||||||
|
|
||||||
### Ошибка: "relation does not exist"
|
|
||||||
- Запустите миграции: `python manage.py migrate_schemas`
|
|
||||||
- Проверьте, что схема создана в PostgreSQL
|
|
||||||
|
|
||||||
### Ошибка подключения к PostgreSQL:
|
|
||||||
- Проверьте, что PostgreSQL запущен
|
|
||||||
- Проверьте параметры подключения в `settings.py`
|
|
||||||
- Проверьте, что база данных `inventory_db` существует
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Продакшн
|
|
||||||
|
|
||||||
Для продакшна (на сервере):
|
|
||||||
|
|
||||||
1. Измените `settings.py`:
|
|
||||||
```python
|
|
||||||
DEBUG = False
|
|
||||||
ALLOWED_HOSTS = ['.inventory.by']
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Настройте DNS для поддоменов (wildcard):
|
|
||||||
```
|
|
||||||
*.inventory.by → ваш сервер
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Используйте реальные домены вместо localhost
|
|
||||||
|
|
||||||
4. Настройте PostgreSQL с безопасным паролем
|
|
||||||
|
|
||||||
5. Используйте environment variables для секретов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Следующие шаги
|
|
||||||
|
|
||||||
После успешной настройки:
|
|
||||||
|
|
||||||
1. ✅ Создайте несколько тестовых магазинов
|
|
||||||
2. ✅ Добавьте товары в каждый магазин
|
|
||||||
3. ✅ Создайте тестовые заказы
|
|
||||||
4. ✅ Проверьте изоляцию данных между магазинами
|
|
||||||
5. 🔜 Разработайте веб-интерфейс для владельцев магазинов
|
|
||||||
6. 🔜 Добавьте регистрацию новых магазинов через веб-форму
|
|
||||||
7. 🔜 Реализуйте биллинг и тарифные планы
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# Быстрый старт - Django Tenants
|
|
||||||
|
|
||||||
## 1. Установка PostgreSQL
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Docker (рекомендуется):
|
|
||||||
docker run --name inventory-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=inventory_db -p 5432:5432 -d postgres:15
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Установка пакетов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Миграции
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations
|
|
||||||
python manage.py migrate_schemas --shared
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Создание public тенанта
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tenants.models import Client, Domain
|
|
||||||
|
|
||||||
public_tenant = Client.objects.create(
|
|
||||||
schema_name='public',
|
|
||||||
name='Главный домен',
|
|
||||||
owner_email='admin@inventory.by',
|
|
||||||
owner_name='Администратор'
|
|
||||||
)
|
|
||||||
|
|
||||||
Domain.objects.create(
|
|
||||||
domain='localhost',
|
|
||||||
tenant=public_tenant,
|
|
||||||
is_primary=True
|
|
||||||
)
|
|
||||||
exit()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Создание суперпользователя
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py createsuperuser --schema=public
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Создание тестового магазина
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py create_tenant
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Добавить в hosts
|
|
||||||
|
|
||||||
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
|
|
||||||
|
|
||||||
```
|
|
||||||
127.0.0.1 localhost
|
|
||||||
127.0.0.1 shop1.localhost
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py runserver 0.0.0.0:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. Проверка
|
|
||||||
|
|
||||||
- Админка системы: http://localhost:8000/admin/
|
|
||||||
- Админка магазина: http://shop1.localhost:8000/admin/
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Подробная инструкция**: см. [DJANGO_TENANTS_SETUP.md](DJANGO_TENANTS_SETUP.md)
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# Старт проекта с нуля
|
|
||||||
|
|
||||||
## 1. База данных в Docker
|
|
||||||
```bash
|
|
||||||
docker run --name inventory-postgres \
|
|
||||||
-e POSTGRES_PASSWORD=postgres \
|
|
||||||
-e POSTGRES_DB=inventory_db \
|
|
||||||
-p 5432:5432 \
|
|
||||||
-d postgres:15
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Создать миграции
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Применить миграции к public схеме
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas --shared
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Создать PUBLIC тенант (обязательно!)
|
|
||||||
```bash
|
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
Вставить в shell:
|
|
||||||
```python
|
|
||||||
from tenants.models import Client, Domain
|
|
||||||
|
|
||||||
public = Client.objects.create(
|
|
||||||
schema_name='public',
|
|
||||||
name='Admin Panel',
|
|
||||||
owner_email='admin@localhost',
|
|
||||||
owner_name='Admin'
|
|
||||||
)
|
|
||||||
|
|
||||||
Domain.objects.create(
|
|
||||||
domain='localhost',
|
|
||||||
tenant=public,
|
|
||||||
is_primary=True
|
|
||||||
)
|
|
||||||
|
|
||||||
print('Public tenant created!')
|
|
||||||
exit()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Создать суперпользователя для public
|
|
||||||
```bash
|
|
||||||
python manage.py createsuperuser --schema=public
|
|
||||||
```
|
|
||||||
|
|
||||||
Введи:
|
|
||||||
- Email: admin@localhost
|
|
||||||
- Password: AdminPassword123
|
|
||||||
|
|
||||||
## 6. Запустить сервер
|
|
||||||
```bash
|
|
||||||
python manage.py runserver 0.0.0.0:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Все! Теперь:
|
|
||||||
|
|
||||||
- Админка: http://localhost:8000/admin/
|
|
||||||
- Новые тенанты создаются только через форму регистрации → одобрение в админке
|
|
||||||
|
|
||||||
**ВАЖНО:** НЕ СОЗДАВАЙ НИКАКИХ ПОЛЬЗОВАТЕЛЕЙ ВРУЧНУЮ! Все создается автоматически при одобрении заявки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Учетные данные для новых тенантов
|
|
||||||
Email: admin@localhost
|
|
||||||
Password: AdminPassword123
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
# 🚀 Чистый старт проекта с Django Tenants
|
|
||||||
|
|
||||||
Все миграции удалены. База данных пуста. Готов к чистому старту!
|
|
||||||
|
|
||||||
## ✅ Что уже сделано:
|
|
||||||
|
|
||||||
1. ✅ PostgreSQL установлен и запущен в Docker
|
|
||||||
2. ✅ Все старые миграции удалены
|
|
||||||
3. ✅ SQLite база удалена
|
|
||||||
4. ✅ Проект настроен для django-tenants
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Пошаговая инструкция:
|
|
||||||
|
|
||||||
### Шаг 1: Установить зависимости
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Это установит:
|
|
||||||
- django-tenants
|
|
||||||
- psycopg2-binary
|
|
||||||
- и все остальные зависимости
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 2: Создать миграции для всех приложений
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations
|
|
||||||
```
|
|
||||||
|
|
||||||
Django создаст миграции для:
|
|
||||||
- **tenants** (public схема - Client и Domain)
|
|
||||||
- **accounts, customers, shops, products, orders, inventory** (tenant схемы)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 3: Применить миграции к public схеме
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas --shared
|
|
||||||
```
|
|
||||||
|
|
||||||
Это создаст:
|
|
||||||
- Схему `public` в PostgreSQL
|
|
||||||
- Таблицы для управления тенантами (Client, Domain)
|
|
||||||
- Таблицы Django (auth, contenttypes, sessions, admin)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 4: Создать public тенанта
|
|
||||||
|
|
||||||
Public тенант нужен для главного домена (localhost в разработке).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
В shell выполните:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tenants.models import Client, Domain
|
|
||||||
|
|
||||||
# Создать public тенанта
|
|
||||||
public_tenant = Client.objects.create(
|
|
||||||
schema_name='public',
|
|
||||||
name='Главный домен',
|
|
||||||
owner_email='admin@inventory.by',
|
|
||||||
owner_name='Администратор'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Создать домен для public
|
|
||||||
public_domain = Domain.objects.create(
|
|
||||||
domain='localhost',
|
|
||||||
tenant=public_tenant,
|
|
||||||
is_primary=True
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f'✓ Public тенант создан: {public_tenant}')
|
|
||||||
print(f'✓ Public домен создан: {public_domain}')
|
|
||||||
exit()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 5: Создать суперпользователя (ваш аккаунт)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py createsuperuser --schema=public
|
|
||||||
```
|
|
||||||
|
|
||||||
Введите:
|
|
||||||
- **Email**: ваш email
|
|
||||||
- **Name**: ваше имя
|
|
||||||
- **Password**: ваш пароль
|
|
||||||
|
|
||||||
Этот суперпользователь будет иметь доступ к админке на `localhost:8000/admin/` для управления тенантами.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 6: Создать первый магазин (тенант)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py create_tenant
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример данных:
|
|
||||||
- **Название магазина**: Цветочный рай
|
|
||||||
- **Схема БД**: shop1
|
|
||||||
- **Домен**: shop1.localhost (или оставьте по умолчанию)
|
|
||||||
- **Имя владельца**: Иван Иванов
|
|
||||||
- **Email**: ivan@example.com
|
|
||||||
- **Телефон**: (можете оставить пустым)
|
|
||||||
|
|
||||||
Команда автоматически:
|
|
||||||
1. Создаст тенанта в таблице `Client`
|
|
||||||
2. Создаст домен в таблице `Domain`
|
|
||||||
3. Создаст схему `shop1` в PostgreSQL
|
|
||||||
4. Применит все миграции к схеме `shop1`
|
|
||||||
5. Создаст все таблицы (customers, orders, products, etc.) в схеме `shop1`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 7: Настроить hosts файл
|
|
||||||
|
|
||||||
Откройте файл hosts с правами администратора:
|
|
||||||
|
|
||||||
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
|
|
||||||
|
|
||||||
Добавьте строки:
|
|
||||||
|
|
||||||
```
|
|
||||||
127.0.0.1 localhost
|
|
||||||
127.0.0.1 shop1.localhost
|
|
||||||
127.0.0.1 shop2.localhost
|
|
||||||
```
|
|
||||||
|
|
||||||
Сохраните файл.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 8: Запустить сервер
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py runserver 0.0.0.0:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 9: Проверить работу
|
|
||||||
|
|
||||||
#### 1. Админка супер-администратора (Public схема):
|
|
||||||
|
|
||||||
URL: **http://localhost:8000/admin/**
|
|
||||||
|
|
||||||
Логин: email и пароль суперпользователя (из Шага 5)
|
|
||||||
|
|
||||||
Здесь вы увидите:
|
|
||||||
- Управление тенантами (магазинами)
|
|
||||||
- Управление доменами
|
|
||||||
- Стандартные разделы Django
|
|
||||||
|
|
||||||
#### 2. Админка магазина (Tenant схема):
|
|
||||||
|
|
||||||
URL: **http://shop1.localhost:8000/admin/**
|
|
||||||
|
|
||||||
Сначала нужно создать пользователя для магазина:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py tenant_command createsuperuser --schema=shop1
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем зайдите в админку магазина и увидите:
|
|
||||||
- Клиенты (Customers)
|
|
||||||
- Адреса (Addresses)
|
|
||||||
- Магазины/точки (Shops)
|
|
||||||
- Товары (Products, Categories, Kits)
|
|
||||||
- Заказы (Orders, OrderItems)
|
|
||||||
- Складской учет (Inventory)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Проверка изоляции данных
|
|
||||||
|
|
||||||
Создайте второй магазин:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py create_tenant
|
|
||||||
```
|
|
||||||
|
|
||||||
Данные (название: "Второй магазин", схема: "shop2", домен: "shop2.localhost")
|
|
||||||
|
|
||||||
Затем:
|
|
||||||
1. Добавьте товары в shop1
|
|
||||||
2. Добавьте товары в shop2
|
|
||||||
3. Убедитесь, что товары из shop1 НЕ видны в shop2 и наоборот
|
|
||||||
|
|
||||||
**Это и есть полная изоляация данных!** ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠 Полезные команды
|
|
||||||
|
|
||||||
### Посмотреть список всех тенантов:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py shell
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
from tenants.models import Client
|
|
||||||
for tenant in Client.objects.all():
|
|
||||||
print(f'{tenant.name}: {tenant.schema_name} - {tenant.get_primary_domain()}')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Применить миграции ко всем тенантам:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas
|
|
||||||
```
|
|
||||||
|
|
||||||
### Применить миграции к конкретному тенанту:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas --schema=shop1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Выполнить команду для тенанта:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py tenant_command <command> --schema=shop1
|
|
||||||
```
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
```bash
|
|
||||||
python manage.py tenant_command createsuperuser --schema=shop1
|
|
||||||
python manage.py tenant_command shell --schema=shop1
|
|
||||||
python manage.py tenant_command dumpdata --schema=shop1 > shop1_data.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Структура базы данных
|
|
||||||
|
|
||||||
После выполнения всех шагов в PostgreSQL будет:
|
|
||||||
|
|
||||||
### Схема `public`:
|
|
||||||
- Таблицы тенантов: `tenants_client`, `tenants_domain`
|
|
||||||
- Таблицы Django: `auth_user`, `auth_group`, `django_session`, etc.
|
|
||||||
|
|
||||||
### Схема `shop1`:
|
|
||||||
- `customers_customer`, `customers_address`
|
|
||||||
- `shops_shop`
|
|
||||||
- `products_product`, `products_category`, `products_productkit`
|
|
||||||
- `orders_order`, `orders_orderitem`
|
|
||||||
- `inventory_*`
|
|
||||||
- И все остальные таблицы приложений
|
|
||||||
|
|
||||||
### Схема `shop2`:
|
|
||||||
- Те же таблицы что и в `shop1`, но с ДРУГИМИ данными!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❗ Возможные проблемы
|
|
||||||
|
|
||||||
### Ошибка: "connection to server at localhost (127.0.0.1), port 5432 failed"
|
|
||||||
PostgreSQL не запущен. Запустите:
|
|
||||||
```bash
|
|
||||||
docker start inventory-postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ошибка: "database 'inventory_db' does not exist"
|
|
||||||
Создайте базу:
|
|
||||||
```bash
|
|
||||||
docker exec -it inventory-postgres psql -U postgres -c "CREATE DATABASE inventory_db;"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ошибка: "No tenant found for hostname 'shop1.localhost'"
|
|
||||||
- Проверьте hosts файл
|
|
||||||
- Проверьте, что домен создан: `Domain.objects.filter(domain='shop1.localhost').exists()`
|
|
||||||
|
|
||||||
### Ошибка: "relation does not exist"
|
|
||||||
Миграции не применены. Запустите:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate_schemas
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Готово!
|
|
||||||
|
|
||||||
После выполнения всех шагов у вас будет работающая SaaS-платформа с полной изоляцией данных между магазинами!
|
|
||||||
|
|
||||||
**Подробная документация**: [DJANGO_TENANTS_SETUP.md](DJANGO_TENANTS_SETUP.md)
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
# Руководство по автоматическому созданию суперпользователей для тенантов
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
При создании нового тенанта (магазина) система **автоматически** создает суперпользователя с credentials из файла `.env`. Это позволяет сразу после активации войти в админ-панель тенанта и начать работу.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Настройка
|
|
||||||
|
|
||||||
### 1. Файл `.env`
|
|
||||||
|
|
||||||
В корне проекта находится файл [.env](myproject/.env) с настройками:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Настройки автоматического создания суперпользователя для новых тенантов
|
|
||||||
TENANT_ADMIN_EMAIL=admin@localhost
|
|
||||||
TENANT_ADMIN_PASSWORD=1234
|
|
||||||
TENANT_ADMIN_NAME=Admin
|
|
||||||
```
|
|
||||||
|
|
||||||
**Важно для продакшена:**
|
|
||||||
- Измените пароль на более безопасный
|
|
||||||
- Используйте надежный email
|
|
||||||
- Не коммитьте `.env` в git (уже добавлен в `.gitignore`)
|
|
||||||
|
|
||||||
### 2. Шаблон `.env.example`
|
|
||||||
|
|
||||||
Для других разработчиков создан файл [.env.example](myproject/.env.example) - скопируйте его в `.env` и настройте:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Отредактируйте .env своими значениями
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Как это работает
|
|
||||||
|
|
||||||
### При активации через админку
|
|
||||||
|
|
||||||
1. Заходите в админ-панель: `http://localhost:8000/admin/`
|
|
||||||
2. Раздел "Заявки на регистрацию"
|
|
||||||
3. Нажимаете кнопку "Активировать" напротив заявки
|
|
||||||
|
|
||||||
**Автоматически выполняется:**
|
|
||||||
- Создается тенант (Client)
|
|
||||||
- Создается домен ({schema_name}.localhost)
|
|
||||||
- Создается триальная подписка (90 дней)
|
|
||||||
- **Создается суперпользователь** с credentials из `.env`
|
|
||||||
- Обновляется статус заявки на "Одобрено"
|
|
||||||
|
|
||||||
### При активации через скрипт
|
|
||||||
|
|
||||||
#### Универсальный скрипт [activate_tenant.py](myproject/activate_tenant.py):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd c:\Users\team_\Desktop\test_qwen\myproject
|
|
||||||
"c:\Users\team_\Desktop\test_qwen\venv\Scripts\python.exe" activate_tenant.py grach
|
|
||||||
```
|
|
||||||
|
|
||||||
Где `grach` - это schema_name заявки.
|
|
||||||
|
|
||||||
**Вывод скрипта:**
|
|
||||||
```
|
|
||||||
Найдена заявка: Цветы грач (grach)
|
|
||||||
Статус: Ожидает проверки
|
|
||||||
Email: owner@example.com
|
|
||||||
|
|
||||||
Начинаю активацию...
|
|
||||||
|
|
||||||
1. Создание тенанта: grach
|
|
||||||
[OK] Тенант создан (ID: 5)
|
|
||||||
2. Создание домена: grach.localhost
|
|
||||||
[OK] Домен создан (ID: 4)
|
|
||||||
3. Создание триальной подписки на 90 дней
|
|
||||||
[OK] Подписка создана (ID: 2)
|
|
||||||
Истекает: 2026-01-25 (89 дней)
|
|
||||||
4. Создание суперпользователя для тенанта
|
|
||||||
[OK] Суперпользователь создан (ID: 1)
|
|
||||||
5. Обновление статуса заявки
|
|
||||||
[OK] Заявка помечена как "Одобрено"
|
|
||||||
|
|
||||||
======================================================================
|
|
||||||
АКТИВАЦИЯ ЗАВЕРШЕНА УСПЕШНО!
|
|
||||||
======================================================================
|
|
||||||
Магазин: Цветы грач
|
|
||||||
Schema: grach
|
|
||||||
Домен: http://grach.localhost:8000/
|
|
||||||
Подписка до: 2026-01-25 (89 дней)
|
|
||||||
|
|
||||||
Доступ к админке тенанта:
|
|
||||||
URL: http://grach.localhost:8000/admin/
|
|
||||||
Email: admin@localhost
|
|
||||||
Password: 1234
|
|
||||||
======================================================================
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Доступ к админке тенанта
|
|
||||||
|
|
||||||
После создания тенанта доступ к его админ-панели:
|
|
||||||
|
|
||||||
**URL:** `http://{schema_name}.localhost:8000/admin/`
|
|
||||||
|
|
||||||
**Credentials:**
|
|
||||||
- Email: значение из `TENANT_ADMIN_EMAIL` (.env)
|
|
||||||
- Password: значение из `TENANT_ADMIN_PASSWORD` (.env)
|
|
||||||
|
|
||||||
### Пример для тенанта "grach":
|
|
||||||
|
|
||||||
```
|
|
||||||
URL: http://grach.localhost:8000/admin/
|
|
||||||
Email: admin@localhost
|
|
||||||
Password: 1234
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример для тенанта "mixflowers":
|
|
||||||
|
|
||||||
```
|
|
||||||
URL: http://mixflowers.localhost:8000/admin/
|
|
||||||
Email: admin@localhost
|
|
||||||
Password: 1234
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Создание дополнительных суперпользователей
|
|
||||||
|
|
||||||
Если нужно создать еще одного суперпользователя для конкретного тенанта, используйте скрипт [switch_to_tenant.py](myproject/switch_to_tenant.py):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd c:\Users\team_\Desktop\test_qwen\myproject
|
|
||||||
"c:\Users\team_\Desktop\test_qwen\venv\Scripts\python.exe" switch_to_tenant.py grach
|
|
||||||
```
|
|
||||||
|
|
||||||
Откроется интерактивная оболочка Python в контексте тенанта "grach":
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Вы уже находитесь в схеме тенанта
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
user = User.objects.create_superuser(
|
|
||||||
email='another_admin@localhost',
|
|
||||||
name='Another Admin',
|
|
||||||
password='password123'
|
|
||||||
)
|
|
||||||
print(f'Создан пользователь: {user.email}')
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Технические детали
|
|
||||||
|
|
||||||
### Модель пользователя
|
|
||||||
|
|
||||||
Проект использует кастомную модель пользователя [CustomUser](myproject/accounts/models.py):
|
|
||||||
|
|
||||||
- **USERNAME_FIELD** = `email` (вход по email, а не username)
|
|
||||||
- **REQUIRED_FIELDS** = `['name']` (обязательно имя)
|
|
||||||
- Username автоматически = email для совместимости
|
|
||||||
|
|
||||||
### Метод создания суперпользователя
|
|
||||||
|
|
||||||
```python
|
|
||||||
User.objects.create_superuser(
|
|
||||||
email='admin@localhost', # из TENANT_ADMIN_EMAIL
|
|
||||||
name='Admin', # из TENANT_ADMIN_NAME
|
|
||||||
password='1234' # из TENANT_ADMIN_PASSWORD
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Переключение между схемами
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.db import connection
|
|
||||||
from tenants.models import Client
|
|
||||||
|
|
||||||
# Переключиться на тенанта
|
|
||||||
client = Client.objects.get(schema_name='grach')
|
|
||||||
connection.set_tenant(client)
|
|
||||||
|
|
||||||
# Теперь все запросы к БД идут в схему "grach"
|
|
||||||
User.objects.all() # Пользователи тенанта "grach"
|
|
||||||
|
|
||||||
# Вернуться в public схему
|
|
||||||
public_tenant = Client.objects.get(schema_name='public')
|
|
||||||
connection.set_tenant(public_tenant)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Для локальной разработки
|
|
||||||
|
|
||||||
Текущие настройки подходят:
|
|
||||||
```env
|
|
||||||
TENANT_ADMIN_EMAIL=admin@localhost
|
|
||||||
TENANT_ADMIN_PASSWORD=1234
|
|
||||||
TENANT_ADMIN_NAME=Admin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Для продакшена
|
|
||||||
|
|
||||||
**ОБЯЗАТЕЛЬНО измените:**
|
|
||||||
|
|
||||||
1. **Пароль:**
|
|
||||||
```env
|
|
||||||
TENANT_ADMIN_PASSWORD=сложный-случайный-пароль-min-16-символов
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Email:**
|
|
||||||
```env
|
|
||||||
TENANT_ADMIN_EMAIL=admin@yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Дополнительно:**
|
|
||||||
- Включите двухфакторную аутентификацию (2FA)
|
|
||||||
- Настройте IP whitelist для админки
|
|
||||||
- Используйте HTTPS
|
|
||||||
- Регулярно меняйте пароль
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Частые вопросы
|
|
||||||
|
|
||||||
### Q: Как изменить пароль для существующих тенантов?
|
|
||||||
|
|
||||||
A: Используйте скрипт `switch_to_tenant.py`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python switch_to_tenant.py grach
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем в интерактивной оболочке:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
user = User.objects.get(email='admin@localhost')
|
|
||||||
user.set_password('новый-пароль')
|
|
||||||
user.save()
|
|
||||||
print(f'Пароль обновлен для {user.email}')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: Что если я забыл пароль от админки тенанта?
|
|
||||||
|
|
||||||
A: Используйте тот же метод что выше для сброса пароля.
|
|
||||||
|
|
||||||
### Q: Можно ли использовать разные пароли для разных тенантов?
|
|
||||||
|
|
||||||
A: Сейчас все тенанты получают одинаковые credentials из `.env`. Если нужны уникальные пароли для каждого тенанта:
|
|
||||||
|
|
||||||
1. Вариант A: Генерируйте случайный пароль при создании и сохраняйте в notes тенанта
|
|
||||||
2. Вариант B: Отправляйте credentials на email владельца
|
|
||||||
3. Вариант C: Требуйте смены пароля при первом входе
|
|
||||||
|
|
||||||
### Q: Как дать доступ владельцу магазина?
|
|
||||||
|
|
||||||
A: Есть несколько вариантов:
|
|
||||||
|
|
||||||
**Вариант 1:** Использовать тот же email `admin@localhost` (быстро для разработки)
|
|
||||||
|
|
||||||
**Вариант 2:** Создать отдельного пользователя для владельца:
|
|
||||||
|
|
||||||
```python
|
|
||||||
python switch_to_tenant.py grach
|
|
||||||
|
|
||||||
# В оболочке:
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
owner = User.objects.create_superuser(
|
|
||||||
email='owner@grach.com', # Email владельца из заявки
|
|
||||||
name='Владелец магазина',
|
|
||||||
password='временный-пароль'
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем отправьте владельцу:
|
|
||||||
- URL: `http://grach.localhost:8000/admin/`
|
|
||||||
- Email: `owner@grach.com`
|
|
||||||
- Password: `временный-пароль`
|
|
||||||
- Попросите сменить пароль при первом входе
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Обновленные файлы
|
|
||||||
|
|
||||||
1. [.env](myproject/.env) - переменные окружения (НЕ коммитить!)
|
|
||||||
2. [.env.example](myproject/.env.example) - шаблон для разработчиков
|
|
||||||
3. [settings.py](myproject/myproject/settings.py) - подключен django-environ
|
|
||||||
4. [tenants/admin.py](myproject/tenants/admin.py) - автосоздание суперпользователя
|
|
||||||
5. [activate_tenant.py](myproject/activate_tenant.py) - универсальный скрипт активации
|
|
||||||
6. [.gitignore](myproject/.gitignore) - защита секретов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Примеры использования
|
|
||||||
|
|
||||||
### Сценарий 1: Активация новой заявки через админку
|
|
||||||
|
|
||||||
```
|
|
||||||
1. http://localhost:8000/admin/ → вход как супер-админ
|
|
||||||
2. Заявки на регистрацию → найти pending заявку
|
|
||||||
3. Нажать "Активировать"
|
|
||||||
4. Готово! Доступ: http://{schema_name}.localhost:8000/admin/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 2: Активация через скрипт
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd c:\Users\team_\Desktop\test_qwen\myproject
|
|
||||||
"c:\Users\team_\Desktop\test_qwen\venv\Scripts\python.exe" activate_tenant.py myshop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сценарий 3: Вход в админку тенанта
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Открыть: http://myshop.localhost:8000/admin/
|
|
||||||
2. Email: admin@localhost
|
|
||||||
3. Password: 1234
|
|
||||||
4. Готово!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Вопросы?** Проверьте логи Django или обратитесь к документации по django-tenants.
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
# Руководство по системе регистрации тенантов
|
|
||||||
|
|
||||||
## Что реализовано
|
|
||||||
|
|
||||||
Создана полноценная система регистрации новых магазинов (тенантов) с ручной модерацией администратором.
|
|
||||||
|
|
||||||
### 1. Модели данных ([tenants/models.py](myproject/tenants/models.py))
|
|
||||||
|
|
||||||
#### Client (обновлена)
|
|
||||||
- Добавлен `db_index` для поля `name` (ускорение поиска)
|
|
||||||
- Изменено поле `phone` на `PhoneNumberField` (поддержка РБ/РФ форматов)
|
|
||||||
- Обновлен `help_text` для `owner_email` (один email может быть у нескольких магазинов для супер-админа)
|
|
||||||
|
|
||||||
#### TenantRegistration (новая)
|
|
||||||
Модель заявки на регистрацию:
|
|
||||||
- `shop_name` - название магазина
|
|
||||||
- `schema_name` - желаемый поддомен (с валидацией regex)
|
|
||||||
- `owner_email`, `owner_name`, `phone` - контактные данные
|
|
||||||
- `status` - статус заявки: pending/approved/rejected
|
|
||||||
- `processed_at`, `processed_by` - данные обработки
|
|
||||||
- `tenant` - ссылка на созданный тенант после активации
|
|
||||||
|
|
||||||
#### Subscription (новая)
|
|
||||||
Модель подписки:
|
|
||||||
- `plan` - тип плана (триал 90 дней, месяц, квартал, год)
|
|
||||||
- `started_at`, `expires_at` - период действия
|
|
||||||
- `is_active`, `auto_renew` - статус и автопродление
|
|
||||||
- Методы: `is_expired()`, `days_left()`, `create_trial(client)`
|
|
||||||
|
|
||||||
#### RESERVED_SCHEMA_NAMES
|
|
||||||
Список зарезервированных поддоменов (admin, api, www, и т.д.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Админ-панель ([tenants/admin.py](myproject/tenants/admin.py))
|
|
||||||
|
|
||||||
#### ClientAdmin (обновлена)
|
|
||||||
- Добавлена колонка `subscription_status` с цветовой индикацией
|
|
||||||
- Разрешено редактирование `schema_name` при создании нового тенанта
|
|
||||||
- Запрещено удаление тенантов через админку (для безопасности)
|
|
||||||
|
|
||||||
#### TenantRegistrationAdmin (новая)
|
|
||||||
Функции:
|
|
||||||
- Список заявок с фильтрами по статусу и дате
|
|
||||||
- Кнопки "Активировать" / "Отклонить" для каждой заявки
|
|
||||||
- Массовые действия для обработки нескольких заявок
|
|
||||||
- При активации:
|
|
||||||
- Создается тенант (Client)
|
|
||||||
- Создается домен (например: myshop.localhost)
|
|
||||||
- Создается триальная подписка на 90 дней
|
|
||||||
- Заявка помечается как "Одобрено"
|
|
||||||
|
|
||||||
#### SubscriptionAdmin (новая)
|
|
||||||
- Просмотр и управление подписками
|
|
||||||
- Цветовая индикация истекающих подписок (красный < 0 дней, оранжевый < 7 дней)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Публичная форма регистрации
|
|
||||||
|
|
||||||
#### [tenants/forms.py](myproject/tenants/forms.py) - TenantRegistrationForm
|
|
||||||
Валидация:
|
|
||||||
- `schema_name`: приведение к lowercase, проверка длины (3-63 символа), проверка на зарезервированные имена, проверка уникальности
|
|
||||||
- `owner_email`: проверка на дубликаты pending заявок
|
|
||||||
|
|
||||||
#### [tenants/views.py](myproject/tenants/views.py)
|
|
||||||
- `TenantRegistrationView` - форма регистрации
|
|
||||||
- `RegistrationSuccessView` - страница благодарности
|
|
||||||
|
|
||||||
#### HTML шаблоны
|
|
||||||
- [base.html](myproject/tenants/templates/tenants/base.html) - базовый шаблон с Bootstrap 5
|
|
||||||
- [registration_form.html](myproject/tenants/templates/tenants/registration_form.html) - красивая форма с валидацией
|
|
||||||
- [registration_success.html](myproject/tenants/templates/tenants/registration_success.html) - страница с инструкциями
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Как использовать
|
|
||||||
|
|
||||||
### Для пользователей (владельцев будущих магазинов)
|
|
||||||
|
|
||||||
1. Откройте публичную форму регистрации:
|
|
||||||
```
|
|
||||||
http://localhost:8000/register/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Заполните форму:
|
|
||||||
- Название магазина
|
|
||||||
- Желаемый поддомен (только латиница, цифры, дефис)
|
|
||||||
- Ваше имя
|
|
||||||
- Email
|
|
||||||
- Телефон
|
|
||||||
|
|
||||||
3. После отправки увидите страницу благодарности
|
|
||||||
|
|
||||||
4. Ожидайте активации администратором (в течение 24 часов)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Для администратора
|
|
||||||
|
|
||||||
1. Войдите в админ-панель:
|
|
||||||
```
|
|
||||||
http://localhost:8000/admin/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Перейдите в раздел "Заявки на регистрацию"
|
|
||||||
|
|
||||||
3. Увидите список заявок со статусом "Ожидает проверки"
|
|
||||||
|
|
||||||
4. Для активации заявки:
|
|
||||||
- Кликните на кнопку "Активировать" справа от заявки
|
|
||||||
- ИЛИ выберите несколько заявок и используйте массовое действие
|
|
||||||
|
|
||||||
5. Что происходит при активации:
|
|
||||||
- Создается новый тенант (Client) с указанным schema_name
|
|
||||||
- Создается домен `{schema_name}.localhost`
|
|
||||||
- Создается триальная подписка на 90 дней
|
|
||||||
- Заявка помечается как "Одобрено"
|
|
||||||
- В поле "Созданный тенант" появляется ссылка на тенант
|
|
||||||
|
|
||||||
6. Для отклонения:
|
|
||||||
- Кликните "Отклонить"
|
|
||||||
- Заявка помечается как "Отклонено"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Доступ к магазинам
|
|
||||||
|
|
||||||
После активации магазин доступен по адресу:
|
|
||||||
```
|
|
||||||
http://{schema_name}.localhost:8000/
|
|
||||||
```
|
|
||||||
|
|
||||||
Например, для магазина с `schema_name=myshop`:
|
|
||||||
```
|
|
||||||
http://myshop.localhost:8000/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Управление подписками
|
|
||||||
|
|
||||||
### Просмотр подписок
|
|
||||||
|
|
||||||
1. Админ-панель → "Подписки"
|
|
||||||
2. Видны все подписки с информацией:
|
|
||||||
- Тип плана
|
|
||||||
- Дата начала/окончания
|
|
||||||
- Осталось дней
|
|
||||||
- Истекла или нет
|
|
||||||
|
|
||||||
### Продление подписки
|
|
||||||
|
|
||||||
1. Откройте подписку тенанта
|
|
||||||
2. Измените:
|
|
||||||
- `expires_at` - новую дату окончания
|
|
||||||
- `plan` - новый тип плана (если меняется)
|
|
||||||
3. Сохраните
|
|
||||||
|
|
||||||
### Типы планов
|
|
||||||
|
|
||||||
- **Триальный (90 дней)** - автоматически при создании
|
|
||||||
- **Месячный** - 30 дней
|
|
||||||
- **Квартальный** - 90 дней
|
|
||||||
- **Годовой** - 365 дней
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Технические детали
|
|
||||||
|
|
||||||
### Валидация schema_name
|
|
||||||
|
|
||||||
Regex: `^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$`
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
- Только латинские буквы в нижнем регистре
|
|
||||||
- Цифры и дефис разрешены
|
|
||||||
- Длина 3-63 символа
|
|
||||||
- Не может начинаться или заканчиваться дефисом
|
|
||||||
- Не совпадает с зарезервированными именами
|
|
||||||
|
|
||||||
### Зарезервированные имена
|
|
||||||
|
|
||||||
```python
|
|
||||||
RESERVED_SCHEMA_NAMES = [
|
|
||||||
'public', 'admin', 'api', 'www', 'mail', 'ftp', 'smtp',
|
|
||||||
'static', 'media', 'assets', 'cdn', 'app', 'web',
|
|
||||||
'billing', 'register', 'login', 'logout', 'dashboard',
|
|
||||||
'test', 'dev', 'staging', 'production', 'demo'
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Email для супер-админа
|
|
||||||
|
|
||||||
Один email может использоваться для нескольких магазинов (полезно для вас как супер-админа для входа в разные тенанты).
|
|
||||||
|
|
||||||
Для обычных пользователей форма проверяет наличие pending заявок с таким же email.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что дальше (рекомендации)
|
|
||||||
|
|
||||||
### 1. Email-уведомления
|
|
||||||
|
|
||||||
Добавить отправку писем:
|
|
||||||
- Пользователю при активации заявки
|
|
||||||
- Пользователю при истечении подписки (за 7 дней, за 1 день)
|
|
||||||
- Админу при новой заявке
|
|
||||||
|
|
||||||
### 2. Биллинг
|
|
||||||
|
|
||||||
Создать страницу `/billing/` где владелец магазина может:
|
|
||||||
- Посмотреть текущую подписку
|
|
||||||
- Продлить подписку
|
|
||||||
- Оплатить через платежную систему
|
|
||||||
|
|
||||||
### 3. Middleware для is_active
|
|
||||||
|
|
||||||
Если нужна жесткая блокировка доступа к деактивированным магазинам, создать middleware:
|
|
||||||
```python
|
|
||||||
# tenants/middleware.py
|
|
||||||
class TenantStatusMiddleware:
|
|
||||||
def __call__(self, request):
|
|
||||||
if hasattr(request, 'tenant'):
|
|
||||||
if not request.tenant.is_active:
|
|
||||||
# Показать страницу "Магазин заблокирован"
|
|
||||||
pass
|
|
||||||
|
|
||||||
sub = request.tenant.subscription
|
|
||||||
if sub.is_expired():
|
|
||||||
# Редирект на /billing/renew/
|
|
||||||
pass
|
|
||||||
|
|
||||||
return self.get_response(request)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Автоматическая очистка
|
|
||||||
|
|
||||||
Создать команду для удаления старых отклоненных заявок:
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_old_registrations --days=30
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Структура файлов
|
|
||||||
|
|
||||||
```
|
|
||||||
myproject/tenants/
|
|
||||||
├── models.py # Модели Client, TenantRegistration, Subscription
|
|
||||||
├── admin.py # Админ-панель с функционалом активации
|
|
||||||
├── forms.py # Форма регистрации с валидацией
|
|
||||||
├── views.py # Views для публичной регистрации
|
|
||||||
├── urls.py # Роуты /register/ и /register/success/
|
|
||||||
└── templates/tenants/
|
|
||||||
├── base.html # Базовый шаблон
|
|
||||||
├── registration_form.html # Форма регистрации
|
|
||||||
└── registration_success.html # Страница благодарности
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
### 1. Регистрация магазина
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запустите сервер
|
|
||||||
python manage.py runserver
|
|
||||||
|
|
||||||
# Откройте браузер
|
|
||||||
http://localhost:8000/register/
|
|
||||||
|
|
||||||
# Заполните форму:
|
|
||||||
Название: Тестовый магазин
|
|
||||||
Поддомен: testshop
|
|
||||||
Имя: Иван Иванов
|
|
||||||
Email: test@example.com
|
|
||||||
Телефон: +375291234567
|
|
||||||
|
|
||||||
# Отправьте заявку
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Активация через админку
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Войдите в админку
|
|
||||||
http://localhost:8000/admin/
|
|
||||||
|
|
||||||
# Логин/пароль супер-админа
|
|
||||||
# Перейдите в "Заявки на регистрацию"
|
|
||||||
# Нажмите "Активировать" напротив заявки
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Проверка созданного магазина
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Откройте браузер
|
|
||||||
http://testshop.localhost:8000/
|
|
||||||
|
|
||||||
# Должна открыться страница магазина
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
При возникновении проблем проверьте:
|
|
||||||
|
|
||||||
1. Миграции применены: `python manage.py migrate_schemas --shared`
|
|
||||||
2. В `settings.py` приложение `tenants` в `SHARED_APPS`
|
|
||||||
3. В `urls_public.py` подключены роуты tenants
|
|
||||||
4. Виртуальное окружение активировано
|
|
||||||
5. `phonenumber_field` установлен
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Система готова к использованию!**
|
|
||||||
|
|
||||||
Теперь вы можете:
|
|
||||||
- Принимать заявки на регистрацию
|
|
||||||
- Модерировать их через админку
|
|
||||||
- Управлять подписками
|
|
||||||
- Контролировать доступ к магазинам
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
# Тесты для расчета себестоимости
|
|
||||||
|
|
||||||
## Структура тестов
|
|
||||||
|
|
||||||
```
|
|
||||||
products/tests/
|
|
||||||
├── __init__.py # Импорты всех тестов
|
|
||||||
└── test_cost_calculator.py # Тесты расчета себестоимости (35 тестов)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Созданные тесты
|
|
||||||
|
|
||||||
### ProductCostCalculatorTest (Unit тесты)
|
|
||||||
Тесты чистой логики расчета без signals:
|
|
||||||
|
|
||||||
1. **test_calculate_weighted_average_cost_no_batches** - товар без партий → 0.00
|
|
||||||
2. **test_calculate_weighted_average_cost_single_batch** - одна партия → стоимость партии
|
|
||||||
3. **test_calculate_weighted_average_cost_multiple_batches_same_price** - несколько партий одинаковой цены
|
|
||||||
4. **test_calculate_weighted_average_cost_multiple_batches_different_price** - средневзвешенная из разных цен
|
|
||||||
5. **test_calculate_weighted_average_cost_complex_case** - сложный случай с тремя партиями
|
|
||||||
6. **test_calculate_weighted_average_cost_ignores_inactive_batches** - игнорирует неактивные партии
|
|
||||||
7. **test_calculate_weighted_average_cost_ignores_zero_quantity_batches** - игнорирует пустые партии
|
|
||||||
8. **test_update_product_cost_updates_field** - обновление поля в БД
|
|
||||||
9. **test_update_product_cost_no_save** - работа без сохранения
|
|
||||||
10. **test_update_product_cost_no_change** - обработка случая без изменений
|
|
||||||
11. **test_get_cost_details** - получение детальной информации
|
|
||||||
12. **test_get_cost_details_synced** - проверка флага синхронизации
|
|
||||||
|
|
||||||
### ProductCostCalculatorIntegrationTest (Интеграционные тесты)
|
|
||||||
Тесты автоматического обновления через Django signals:
|
|
||||||
|
|
||||||
1. **test_signal_updates_cost_on_batch_create** - создание партии → автообновление
|
|
||||||
2. **test_signal_updates_cost_on_batch_update** - изменение партии → автообновление
|
|
||||||
3. **test_signal_updates_cost_on_batch_delete** - удаление партии → автообновление
|
|
||||||
4. **test_signal_updates_cost_to_zero_when_all_batches_deleted** - удаление всех → обнуление
|
|
||||||
5. **test_lifecycle_scenario** - полный жизненный цикл товара
|
|
||||||
|
|
||||||
### ProductCostDetailsPropertyTest (Тесты Property)
|
|
||||||
Тесты для property cost_price_details:
|
|
||||||
|
|
||||||
1. **test_cost_price_details_property_exists** - property существует
|
|
||||||
2. **test_cost_price_details_returns_dict** - возвращает правильную структуру
|
|
||||||
3. **test_cost_price_details_with_batches** - корректно отображает партии
|
|
||||||
|
|
||||||
## Запуск тестов
|
|
||||||
|
|
||||||
### Все тесты расчета себестоимости
|
|
||||||
```bash
|
|
||||||
python manage.py test products.tests.test_cost_calculator
|
|
||||||
```
|
|
||||||
|
|
||||||
### Конкретный тест-класс
|
|
||||||
```bash
|
|
||||||
# Только unit тесты
|
|
||||||
python manage.py test products.tests.test_cost_calculator.ProductCostCalculatorTest
|
|
||||||
|
|
||||||
# Только интеграционные тесты
|
|
||||||
python manage.py test products.tests.test_cost_calculator.ProductCostCalculatorIntegrationTest
|
|
||||||
|
|
||||||
# Только тесты property
|
|
||||||
python manage.py test products.tests.test_cost_calculator.ProductCostDetailsPropertyTest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Конкретный метод
|
|
||||||
```bash
|
|
||||||
python manage.py test products.tests.test_cost_calculator.ProductCostCalculatorTest.test_calculate_weighted_average_cost_no_batches
|
|
||||||
```
|
|
||||||
|
|
||||||
### С подробным выводом
|
|
||||||
```bash
|
|
||||||
python manage.py test products.tests.test_cost_calculator --verbosity=2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Все тесты приложения products
|
|
||||||
```bash
|
|
||||||
python manage.py test products
|
|
||||||
```
|
|
||||||
|
|
||||||
## Покрытие тестами
|
|
||||||
|
|
||||||
### Тестируемые модули:
|
|
||||||
- ✅ **ProductCostCalculator.calculate_weighted_average_cost()** - расчет средневзвешенной
|
|
||||||
- ✅ **ProductCostCalculator.update_product_cost()** - обновление кешированной стоимости
|
|
||||||
- ✅ **ProductCostCalculator.get_cost_details()** - получение деталей
|
|
||||||
- ✅ **Product.cost_price_details** - property для UI
|
|
||||||
- ✅ **Django Signals** - автоматическое обновление при изменении партий
|
|
||||||
|
|
||||||
### Покрытые сценарии:
|
|
||||||
- ✅ Товар без партий
|
|
||||||
- ✅ Товар с одной партией
|
|
||||||
- ✅ Товар с несколькими партиями одинаковой цены
|
|
||||||
- ✅ Товар с несколькими партиями разной цены
|
|
||||||
- ✅ Сложные случаи (3+ партии, разные объемы)
|
|
||||||
- ✅ Игнорирование неактивных партий
|
|
||||||
- ✅ Игнорирование пустых партий
|
|
||||||
- ✅ Обновление с сохранением в БД
|
|
||||||
- ✅ Обновление без сохранения
|
|
||||||
- ✅ Случай когда стоимость не изменилась
|
|
||||||
- ✅ Автообновление при создании партии
|
|
||||||
- ✅ Автообновление при изменении партии
|
|
||||||
- ✅ Автообновление при удалении партии
|
|
||||||
- ✅ Обнуление при удалении всех партий
|
|
||||||
- ✅ Полный жизненный цикл товара
|
|
||||||
- ✅ Корректность структуры cost_price_details
|
|
||||||
- ✅ Флаг синхронизации
|
|
||||||
|
|
||||||
## Примеры вывода
|
|
||||||
|
|
||||||
### Успешный запуск
|
|
||||||
```
|
|
||||||
Creating test database for alias 'default'...
|
|
||||||
System check identified no issues (0 silenced).
|
|
||||||
....................
|
|
||||||
----------------------------------------------------------------------
|
|
||||||
Ran 20 tests in 2.345s
|
|
||||||
|
|
||||||
OK
|
|
||||||
Destroying test database for alias 'default'...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск с verbosity=2
|
|
||||||
```
|
|
||||||
test_calculate_weighted_average_cost_complex_case (products.tests.test_cost_calculator.ProductCostCalculatorTest) ... ok
|
|
||||||
test_calculate_weighted_average_cost_multiple_batches_different_price (products.tests.test_cost_calculator.ProductCostCalculatorTest) ... ok
|
|
||||||
test_calculate_weighted_average_cost_multiple_batches_same_price (products.tests.test_cost_calculator.ProductCostCalculatorTest) ... ok
|
|
||||||
test_calculate_weighted_average_cost_no_batches (products.tests.test_cost_calculator.ProductCostCalculatorTest) ... ok
|
|
||||||
test_calculate_weighted_average_cost_single_batch (products.tests.test_cost_calculator.ProductCostCalculatorTest) ... ok
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Отладка тестов
|
|
||||||
|
|
||||||
### Запуск одного теста с PDB
|
|
||||||
```bash
|
|
||||||
python manage.py test products.tests.test_cost_calculator.ProductCostCalculatorTest.test_calculate_weighted_average_cost_no_batches --pdb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сохранение тестовой БД
|
|
||||||
```bash
|
|
||||||
python manage.py test products.tests.test_cost_calculator --keepdb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск в параллель (быстрее)
|
|
||||||
```bash
|
|
||||||
python manage.py test products.tests.test_cost_calculator --parallel
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coverage (опционально)
|
|
||||||
|
|
||||||
Для проверки покрытия кода тестами:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Установить coverage
|
|
||||||
pip install coverage
|
|
||||||
|
|
||||||
# Запустить тесты с измерением покрытия
|
|
||||||
coverage run --source='products' manage.py test products.tests.test_cost_calculator
|
|
||||||
|
|
||||||
# Показать отчет
|
|
||||||
coverage report
|
|
||||||
|
|
||||||
# Создать HTML отчет
|
|
||||||
coverage html
|
|
||||||
# Откройте htmlcov/index.html в браузере
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI/CD Integration
|
|
||||||
|
|
||||||
Пример для GitHub Actions:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Run cost calculator tests
|
|
||||||
run: |
|
|
||||||
python manage.py test products.tests.test_cost_calculator --verbosity=2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Добавление новых тестов
|
|
||||||
|
|
||||||
При добавлении новой функциональности в ProductCostCalculator:
|
|
||||||
|
|
||||||
1. Добавьте unit тесты в `ProductCostCalculatorTest`
|
|
||||||
2. Если есть интеграция с signals - добавьте в `ProductCostCalculatorIntegrationTest`
|
|
||||||
3. Если есть новые property - добавьте в `ProductCostDetailsPropertyTest`
|
|
||||||
4. Запустите все тесты для проверки
|
|
||||||
5. Обновите этот README с описанием новых тестов
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Ошибка: "No module named 'django'"
|
|
||||||
Активируйте виртуальное окружение:
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
venv\Scripts\activate
|
|
||||||
|
|
||||||
# Linux/Mac
|
|
||||||
source venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ошибка: "relation does not exist"
|
|
||||||
Создайте тестовую БД:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Тесты падают с ошибками multi-tenant
|
|
||||||
Убедитесь что используется правильная настройка для тестов в settings.py.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Всего тестов:** 20
|
|
||||||
**Покрытие:** ProductCostCalculator (100%), signals (100%), property (100%)
|
|
||||||
**Время выполнения:** ~2-3 секунды
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# 🧪 Критические тесты переходов между статусами заказов
|
|
||||||
|
|
||||||
## 📋 Описание
|
|
||||||
|
|
||||||
Набор из **5 критических тестов**, проверяющих корректность работы системы резервирования и списания товаров при переходах между статусами заказов.
|
|
||||||
|
|
||||||
## 🎯 Тестируемые сценарии
|
|
||||||
|
|
||||||
### 1️⃣ **Тест "Танцы туда-сюда"** (`test_01_draft_to_completed_to_cancelled_to_completed`)
|
|
||||||
Проверяет множественные переходы между статусами:
|
|
||||||
- `draft` → `completed` → `cancelled` → `completed` (снова)
|
|
||||||
- **Цель:** Убедиться что Sale не дублируется при повторном переходе в `completed`
|
|
||||||
|
|
||||||
### 2️⃣ **Тест отмены из черновика** (`test_02_draft_to_cancelled_releases_reservations`)
|
|
||||||
Проверяет освобождение резервов при отмене:
|
|
||||||
- `draft` → `cancelled`
|
|
||||||
- **Цель:** Резервы должны корректно освобождаться
|
|
||||||
|
|
||||||
### 3️⃣ **Тест возврата из отмены** (`test_03_cancelled_to_pending_reserves_stock`)
|
|
||||||
Проверяет резервирование при возврате:
|
|
||||||
- `draft` → `cancelled` → `pending`
|
|
||||||
- **Цель:** Резервы должны восстанавливаться при возврате из отмены
|
|
||||||
|
|
||||||
### 4️⃣ **Тест промежуточного статуса** (`test_04_create_order_with_intermediate_status`)
|
|
||||||
Проверяет создание заказа с новым статусом:
|
|
||||||
- Создание заказа сразу со статусом "Упаковывается" (не positive, не negative)
|
|
||||||
- **Цель:** Система должна работать с любыми промежуточными статусами
|
|
||||||
|
|
||||||
### 5️⃣ **Тест отката от выполненного** (`test_05_completed_to_draft_rollback_sale`)
|
|
||||||
Проверяет откат Sale:
|
|
||||||
- `draft` → `completed` → `draft`
|
|
||||||
- **Цель:** Sale должен корректно откатываться, товар возвращаться на склад
|
|
||||||
|
|
||||||
## 🚀 Запуск тестов
|
|
||||||
|
|
||||||
### Запуск всех тестов:
|
|
||||||
```bash
|
|
||||||
cd myproject
|
|
||||||
python manage.py test inventory.tests.test_order_status_transitions
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск конкретного теста:
|
|
||||||
```bash
|
|
||||||
python manage.py test inventory.tests.test_order_status_transitions.OrderStatusTransitionCriticalTest.test_01_draft_to_completed_to_cancelled_to_completed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запуск с подробным выводом:
|
|
||||||
```bash
|
|
||||||
python manage.py test inventory.tests.test_order_status_transitions --verbosity=2
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Что проверяет каждый тест
|
|
||||||
|
|
||||||
Все тесты проверяют **3 критических аспекта**:
|
|
||||||
|
|
||||||
### 1. **Корректность Stock**
|
|
||||||
- `quantity_available` - доступное количество
|
|
||||||
- `quantity_reserved` - зарезервированное количество
|
|
||||||
- `quantity_free` - свободное количество (available - reserved)
|
|
||||||
|
|
||||||
### 2. **Корректность Reservation**
|
|
||||||
- Статус резерва (`reserved`, `converted_to_sale`, `released`)
|
|
||||||
- Переходы между статусами при изменении статуса заказа
|
|
||||||
|
|
||||||
### 3. **Корректность Sale и StockBatch**
|
|
||||||
- Sale не дублируется при повторных переходах
|
|
||||||
- StockBatch корректно уменьшается/восстанавливается
|
|
||||||
- SaleBatchAllocation создаётся только один раз
|
|
||||||
|
|
||||||
## 🔍 Пример вывода успешного теста
|
|
||||||
|
|
||||||
```
|
|
||||||
test_01_draft_to_completed_to_cancelled_to_completed ... ok
|
|
||||||
test_02_draft_to_cancelled_releases_reservations ... ok
|
|
||||||
test_03_cancelled_to_pending_reserves_stock ... ok
|
|
||||||
test_04_create_order_with_intermediate_status ... ok
|
|
||||||
test_05_completed_to_draft_rollback_sale ... ok
|
|
||||||
|
|
||||||
----------------------------------------------------------------------
|
|
||||||
Ran 5 tests in 2.341s
|
|
||||||
|
|
||||||
OK
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ Важные особенности
|
|
||||||
|
|
||||||
1. **Тесты используют TransactionTestCase** - каждый тест выполняется в отдельной транзакции
|
|
||||||
2. **Создаётся отдельная схема БД** `test_order_status` для изоляции
|
|
||||||
3. **После каждого теста данные очищаются** - тесты не влияют друг на друга
|
|
||||||
4. **Тесты работают с тенантами** - используется `schema_context()`
|
|
||||||
|
|
||||||
## 🐛 Если тесты падают
|
|
||||||
|
|
||||||
### Проверьте сигналы:
|
|
||||||
- `inventory/signals.py` - все сигналы должны быть подключены
|
|
||||||
- `inventory/apps.py` - сигналы должны импортироваться в `ready()`
|
|
||||||
|
|
||||||
### Проверьте модели:
|
|
||||||
- `Reservation.status` - должны быть варианты: `reserved`, `converted_to_sale`, `released`
|
|
||||||
- `OrderStatus` - должны быть флаги `is_positive_end`, `is_negative_end`
|
|
||||||
|
|
||||||
### Проверьте БД:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Добавление новых тестов
|
|
||||||
|
|
||||||
Чтобы добавить новый тест:
|
|
||||||
|
|
||||||
1. Создайте метод в классе `OrderStatusTransitionCriticalTest`
|
|
||||||
2. Начните имя с `test_` (обязательно!)
|
|
||||||
3. Используйте вспомогательные методы:
|
|
||||||
- `_create_order(status, quantity)` - создать заказ
|
|
||||||
- `_assert_stock_state(available, reserved, free)` - проверить Stock
|
|
||||||
- `_assert_reservation_status(order, expected_status)` - проверить резерв
|
|
||||||
- `_assert_sale_exists(order, should_exist)` - проверить Sale
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
```python
|
|
||||||
def test_06_my_new_scenario(self):
|
|
||||||
"""Описание сценария"""
|
|
||||||
with schema_context('test_order_status'):
|
|
||||||
order = self._create_order(self.status_draft, quantity=Decimal('10.00'))
|
|
||||||
|
|
||||||
# Ваши проверки
|
|
||||||
self._assert_stock_state(
|
|
||||||
available=Decimal('100.00'),
|
|
||||||
reserved=Decimal('10.00'),
|
|
||||||
free=Decimal('90.00')
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎓 Дополнительная информация
|
|
||||||
|
|
||||||
- Документация Django Testing: https://docs.djangoproject.com/en/stable/topics/testing/
|
|
||||||
- Django Tenants Testing: https://django-tenants.readthedocs.io/en/latest/test.html
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Тесты ProductCostCalculator
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
✅ **Тесты написаны и готовы** (20 тестов в [test_cost_calculator.py](test_cost_calculator.py))
|
|
||||||
⚠️ **Требуется настройка test runner для django-tenants**
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
Проект использует django-tenants (multi-tenant архитектура). При запуске стандартных тестов Django создаёт тестовую БД, но не применяет миграции для TENANT_APPS (products, inventory и т.д.), только для SHARED_APPS.
|
|
||||||
|
|
||||||
```
|
|
||||||
ProgrammingError: relation "products_product" does not exist
|
|
||||||
```
|
|
||||||
|
|
||||||
## Решения
|
|
||||||
|
|
||||||
### Решение 1: Использовать django-tenants test runner (рекомендуется)
|
|
||||||
|
|
||||||
Установите и настройте специальный test runner:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# settings.py
|
|
||||||
# Добавьте для тестов:
|
|
||||||
if 'test' in sys.argv:
|
|
||||||
# Для тестов используем простую БД без tenant
|
|
||||||
DATABASES['default']['ENGINE'] = 'django.db.backends.postgresql'
|
|
||||||
# Отключаем multi-tenant для тестов
|
|
||||||
INSTALLED_APPS = SHARED_APPS + TENANT_APPS
|
|
||||||
```
|
|
||||||
|
|
||||||
### Решение 2: Ручное тестирование логики
|
|
||||||
|
|
||||||
Математическая логика уже протестирована в простом Python-скрипте:
|
|
||||||
```bash
|
|
||||||
python test_cost_calculator.py # 6 тестов - все PASS
|
|
||||||
```
|
|
||||||
|
|
||||||
### Решение 3: Тестирование в реальной БД
|
|
||||||
|
|
||||||
Можно тестировать на реальной схеме тенанта:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Django shell
|
|
||||||
python manage.py shell
|
|
||||||
|
|
||||||
# В shell:
|
|
||||||
from decimal import Decimal
|
|
||||||
from products.models import Product
|
|
||||||
from products.services.cost_calculator import ProductCostCalculator
|
|
||||||
from inventory.models import Warehouse, StockBatch
|
|
||||||
|
|
||||||
# Создаём тестовый товар
|
|
||||||
product = Product.objects.create(
|
|
||||||
name='Test Product',
|
|
||||||
sku='TEST-001',
|
|
||||||
cost_price=Decimal('0.00'),
|
|
||||||
price=Decimal('200.00'),
|
|
||||||
unit='шт'
|
|
||||||
)
|
|
||||||
|
|
||||||
warehouse = Warehouse.objects.first()
|
|
||||||
|
|
||||||
# Создаём партию
|
|
||||||
batch = StockBatch.objects.create(
|
|
||||||
product=product,
|
|
||||||
warehouse=warehouse,
|
|
||||||
quantity=Decimal('10.000'),
|
|
||||||
cost_price=Decimal('100.00'),
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверяем автообновление
|
|
||||||
product.refresh_from_db()
|
|
||||||
assert product.cost_price == Decimal('100.00'), "Cost not updated!"
|
|
||||||
|
|
||||||
# Проверяем детали
|
|
||||||
details = product.cost_price_details
|
|
||||||
assert details['cached_cost'] == Decimal('100.00')
|
|
||||||
assert details['calculated_cost'] == Decimal('100.00')
|
|
||||||
assert details['is_synced'] == True
|
|
||||||
assert len(details['batches']) == 1
|
|
||||||
|
|
||||||
print("✓ Все проверки прошли!")
|
|
||||||
|
|
||||||
# Очистка
|
|
||||||
product.delete()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Покрытие тестами
|
|
||||||
|
|
||||||
Несмотря на проблемы с запуском, тесты покрывают:
|
|
||||||
|
|
||||||
### Unit тесты (12 тестов)
|
|
||||||
- ✅ Расчет для товара без партий → 0.00
|
|
||||||
- ✅ Расчет для одной партии
|
|
||||||
- ✅ Расчет для нескольких партий (одинаковая/разная цена)
|
|
||||||
- ✅ Сложные случаи (3+ партии, разные объемы)
|
|
||||||
- ✅ Игнорирование неактивных партий
|
|
||||||
- ✅ Игнорирование пустых партий (quantity=0)
|
|
||||||
- ✅ Обновление с сохранением/без сохранения
|
|
||||||
- ✅ Обработка случая без изменений
|
|
||||||
- ✅ Получение детальной информации
|
|
||||||
|
|
||||||
### Интеграционные тесты (5 тестов)
|
|
||||||
- ✅ Автообновление при создании партии (через signal)
|
|
||||||
- ✅ Автообновление при изменении партии
|
|
||||||
- ✅ Автообновление при удалении партии
|
|
||||||
- ✅ Обнуление при удалении всех партий
|
|
||||||
- ✅ Полный жизненный цикл товара
|
|
||||||
|
|
||||||
### Property тесты (3 теста)
|
|
||||||
- ✅ Property существует
|
|
||||||
- ✅ Возвращает правильную структуру
|
|
||||||
- ✅ Корректно отображает партии
|
|
||||||
|
|
||||||
## Подтверждение работоспособности
|
|
||||||
|
|
||||||
Система **работает в production** - это было проверено при запуске:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py recalculate_product_costs --schema=grach
|
|
||||||
# ✓ Успешно выполнено
|
|
||||||
```
|
|
||||||
|
|
||||||
При добавлении реальной партии в систему, себестоимость автоматически обновилась через Django signals.
|
|
||||||
|
|
||||||
## Рекомендации
|
|
||||||
|
|
||||||
1. **Для разработки:** используйте ручное тестирование через Django shell (см. Решение 3)
|
|
||||||
2. **Для CI/CD:** настройте test runner для django-tenants или используйте отдельную тестовую конфигурацию
|
|
||||||
3. **Математическая корректность:** уже проверена в `test_cost_calculator.py` (простой Python скрипт)
|
|
||||||
|
|
||||||
## Следующие шаги
|
|
||||||
|
|
||||||
Если потребуется полноценный автоматический запуск тестов:
|
|
||||||
|
|
||||||
1. Изучите документацию django-tenants по тестированию
|
|
||||||
2. Настройте TEST_RUNNER в settings.py
|
|
||||||
3. Или создайте отдельный settings_test.py без multi-tenant
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Вывод:** Функционал полностью рабочий и протестированный, тесты написаны и готовы. Проблема только в инфраструктуре запуска тестов для multi-tenant проекта.
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Структура модуля views
|
|
||||||
|
|
||||||
Файл `products/views.py` был разбит на несколько специализированных модулей для улучшения читаемости и поддерживаемости кода.
|
|
||||||
|
|
||||||
## Статистика оптимизации
|
|
||||||
|
|
||||||
### До рефакторинга:
|
|
||||||
- **Файлов:** 1 (`views.py`)
|
|
||||||
- **Строк кода:** 1202
|
|
||||||
- **Дублированный код:** ~400 строк (12 функций управления фото + 3 функции обработки фото)
|
|
||||||
|
|
||||||
### После рефакторинга:
|
|
||||||
- **Файлов:** 7 модулей
|
|
||||||
- **Строк кода:** 1284 (включая документацию)
|
|
||||||
- **Дублированный код:** УСТРАНЁН
|
|
||||||
- **Экономия:** ~400 строк дублированного кода заменены на универсальные функции
|
|
||||||
|
|
||||||
## Структура модулей
|
|
||||||
|
|
||||||
### 1. `__init__.py` (112 строк)
|
|
||||||
Экспортирует все представления для обратной совместимости с `urls.py`.
|
|
||||||
Позволяет использовать импорты вида: `from products.views import ProductListView`
|
|
||||||
|
|
||||||
### 2. `utils.py` (73 строки)
|
|
||||||
Утилиты для работы с фотографиями:
|
|
||||||
- `validate_photo()` - валидация загружаемого фото
|
|
||||||
- `handle_photos()` - **УНИВЕРСАЛЬНАЯ** функция обработки фото (заменяет 3 дублирующиеся функции)
|
|
||||||
|
|
||||||
### 3. `photo_management.py` (310 строк)
|
|
||||||
Универсальные функции управления фотографиями:
|
|
||||||
- **4 базовые функции:** `generic_photo_delete()`, `generic_photo_set_main()`, `generic_photo_move_up()`, `generic_photo_move_down()`
|
|
||||||
- **12 оберток** для Product, ProductKit и Category (заменяют 12 дублирующихся функций из оригинала)
|
|
||||||
|
|
||||||
**Устранённое дублирование:**
|
|
||||||
- Было: 12 отдельных функций (по 4 для каждой модели)
|
|
||||||
- Стало: 4 универсальные функции + 12 простых оберток
|
|
||||||
|
|
||||||
### 4. `product_views.py` (182 строки)
|
|
||||||
CRUD представления для товаров (Product):
|
|
||||||
- `ProductListView` - список товаров с фильтрацией и поиском
|
|
||||||
- `ProductCreateView` - создание товара
|
|
||||||
- `ProductDetailView` - просмотр товара
|
|
||||||
- `ProductUpdateView` - редактирование товара
|
|
||||||
- `ProductDeleteView` - удаление товара
|
|
||||||
|
|
||||||
### 5. `productkit_views.py` (249 строк)
|
|
||||||
CRUD представления для комплектов (ProductKit):
|
|
||||||
- `ProductKitListView` - список комплектов
|
|
||||||
- `ProductKitCreateView` - создание комплекта с компонентами
|
|
||||||
- `ProductKitDetailView` - просмотр комплекта
|
|
||||||
- `ProductKitUpdateView` - редактирование комплекта
|
|
||||||
- `ProductKitDeleteView` - удаление комплекта
|
|
||||||
|
|
||||||
### 6. `category_views.py` (280 строк)
|
|
||||||
CRUD представления для категорий (ProductCategory):
|
|
||||||
- `TreeItem` - класс для элемента дерева категорий
|
|
||||||
- `ProductCategoryListView` - иерархическое дерево категорий с товарами и комплектами
|
|
||||||
- `ProductCategoryCreateView` - создание категории
|
|
||||||
- `ProductCategoryDetailView` - просмотр категории
|
|
||||||
- `ProductCategoryUpdateView` - редактирование категории
|
|
||||||
- `ProductCategoryDeleteView` - удаление категории
|
|
||||||
|
|
||||||
### 7. `api_views.py` (78 строк)
|
|
||||||
API представления:
|
|
||||||
- `search_products_and_variants()` - поиск товаров и групп вариантов для автокомплита
|
|
||||||
|
|
||||||
## Преимущества новой структуры
|
|
||||||
|
|
||||||
### ✅ Устранено дублирование
|
|
||||||
- 12 функций управления фото → 4 универсальные + 12 простых оберток
|
|
||||||
- 3 функции обработки фото → 1 универсальная функция
|
|
||||||
|
|
||||||
### ✅ Улучшена организация
|
|
||||||
- Логическое разделение по функциональным областям
|
|
||||||
- Каждый модуль отвечает за свою сущность (Product, ProductKit, Category)
|
|
||||||
- Легко найти нужный код
|
|
||||||
|
|
||||||
### ✅ Упрощена поддержка
|
|
||||||
- Изменения в одном типе представлений не затрагивают другие
|
|
||||||
- Проще тестировать отдельные компоненты
|
|
||||||
- Легче добавлять новый функционал
|
|
||||||
|
|
||||||
### ✅ Обратная совместимость
|
|
||||||
- Все импорты в `urls.py` работают без изменений
|
|
||||||
- Благодаря `__init__.py` внешний API не изменился
|
|
||||||
|
|
||||||
### ✅ Следование принципам
|
|
||||||
- **SRP (Single Responsibility Principle)** - каждый модуль отвечает за одну область
|
|
||||||
- **DRY (Don't Repeat Yourself)** - устранено дублирование кода
|
|
||||||
- **Separation of Concerns** - разделение по ответственности
|
|
||||||
|
|
||||||
## Примеры использования
|
|
||||||
|
|
||||||
### Импорт представлений (работает как раньше):
|
|
||||||
```python
|
|
||||||
from products.views import ProductListView, ProductCreateView
|
|
||||||
from products.views import productkit_photo_delete
|
|
||||||
from products.views import search_products_and_variants
|
|
||||||
```
|
|
||||||
|
|
||||||
### Импорт из конкретного модуля (новая возможность):
|
|
||||||
```python
|
|
||||||
from products.views.product_views import ProductListView
|
|
||||||
from products.views.photo_management import generic_photo_delete
|
|
||||||
from products.views.utils import validate_photo
|
|
||||||
```
|
|
||||||
|
|
||||||
## Изменения в коде
|
|
||||||
|
|
||||||
### Удалено:
|
|
||||||
- Неиспользуемый импорт `import json`
|
|
||||||
- Комментарий-мусор "Временный файл для добавления в views.py"
|
|
||||||
|
|
||||||
### Добавлено:
|
|
||||||
- Docstrings для всех модулей
|
|
||||||
- Комментарии к универсальным функциям
|
|
||||||
- Документация параметров функций
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
|
|
||||||
После рефакторинга рекомендуется:
|
|
||||||
1. Запустить Django сервер: `python manage.py runserver`
|
|
||||||
2. Проверить все CRUD операции для Product, ProductKit и Category
|
|
||||||
3. Проверить управление фотографиями (upload, delete, set main, move up/down)
|
|
||||||
4. Проверить API endpoint для поиска товаров
|
|
||||||
|
|
||||||
## Потенциальные улучшения
|
|
||||||
|
|
||||||
В будущем можно:
|
|
||||||
1. Добавить базовый класс `BasePhotoView` для дальнейшего упрощения
|
|
||||||
2. Вынести общую логику ListView в миксины
|
|
||||||
3. Добавить unit-тесты для каждого модуля
|
|
||||||
4. Создать отдельный модуль для миксинов и базовых классов
|
|
||||||
@@ -1,771 +0,0 @@
|
|||||||
# План реализации системы личного счета клиента
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Реализация системы личного счета для клиентов цветочного магазина с поддержкой резервирования средств, смешанной оплаты, автоматического пополнения при переплате и полной историей транзакций.
|
|
||||||
|
|
||||||
## Ключевые бизнес-требования
|
|
||||||
|
|
||||||
1. **Баланс счета**: У каждого клиента есть личный счет (может быть положительным или отрицательным)
|
|
||||||
2. **Пополнение**: Вручную администратором или автоматически при переплате заказа
|
|
||||||
3. **Кредитование**: Разрешен отрицательный баланс для доверенных клиентов
|
|
||||||
4. **История операций**: Полный аудит всех операций со счетом
|
|
||||||
5. **Смешанная оплата**: Можно комбинировать с другими способами оплаты
|
|
||||||
6. **Резервирование**: При создании заказа средства резервируются, при завершении списываются
|
|
||||||
7. **Управление**: Только администраторы/менеджеры имеют доступ
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Изменения в базе данных
|
|
||||||
|
|
||||||
### 1.1 Расширение модели Customer
|
|
||||||
|
|
||||||
**Файл**: `myproject/customers/models.py`
|
|
||||||
|
|
||||||
Добавить поля для управления балансом:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Поля баланса
|
|
||||||
account_balance = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Баланс счета",
|
|
||||||
help_text="Текущий баланс лицевого счета клиента"
|
|
||||||
)
|
|
||||||
|
|
||||||
available_balance = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Доступный баланс",
|
|
||||||
help_text="Баланс за вычетом зарезервированных средств"
|
|
||||||
)
|
|
||||||
|
|
||||||
reserved_balance = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Зарезервировано",
|
|
||||||
help_text="Сумма, зарезервированная под активные заказы"
|
|
||||||
)
|
|
||||||
|
|
||||||
allow_negative_balance = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Разрешить отрицательный баланс",
|
|
||||||
help_text="Позволяет клиенту уходить в минус"
|
|
||||||
)
|
|
||||||
|
|
||||||
negative_balance_limit = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Лимит кредита",
|
|
||||||
help_text="Максимальная сумма отрицательного баланса (0 = без лимита)"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Взаимосвязь полей**:
|
|
||||||
- `account_balance` = Общий баланс клиента
|
|
||||||
- `reserved_balance` = Сумма, зарезервированная под заказы
|
|
||||||
- `available_balance` = `account_balance` - `reserved_balance`
|
|
||||||
|
|
||||||
### 1.2 Новая модель AccountTransaction
|
|
||||||
|
|
||||||
**Файл**: `myproject/customers/models.py` (или отдельный файл в models/)
|
|
||||||
|
|
||||||
Модель для хранения истории всех операций со счетом:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class AccountTransaction(models.Model):
|
|
||||||
"""
|
|
||||||
Транзакция по лицевому счету клиента.
|
|
||||||
"""
|
|
||||||
TRANSACTION_TYPE_CHOICES = [
|
|
||||||
('deposit', 'Пополнение вручную'),
|
|
||||||
('auto_deposit', 'Авто-пополнение (переплата)'),
|
|
||||||
('reservation', 'Резервирование'),
|
|
||||||
('reservation_release', 'Снятие резерва'),
|
|
||||||
('charge', 'Списание за заказ'),
|
|
||||||
('refund', 'Возврат средств'),
|
|
||||||
('adjustment', 'Корректировка баланса'),
|
|
||||||
]
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('active', 'Активна'),
|
|
||||||
('completed', 'Завершена'),
|
|
||||||
('cancelled', 'Отменена'),
|
|
||||||
]
|
|
||||||
|
|
||||||
customer = models.ForeignKey(
|
|
||||||
'Customer',
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name='account_transactions'
|
|
||||||
)
|
|
||||||
|
|
||||||
transaction_type = models.CharField(max_length=30, choices=TRANSACTION_TYPE_CHOICES)
|
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
|
||||||
|
|
||||||
balance_before = models.DecimalField(max_digits=10, decimal_places=2)
|
|
||||||
balance_after = models.DecimalField(max_digits=10, decimal_places=2)
|
|
||||||
|
|
||||||
order = models.ForeignKey('orders.Order', null=True, blank=True, on_delete=models.PROTECT)
|
|
||||||
payment = models.ForeignKey('orders.Payment', null=True, blank=True, on_delete=models.SET_NULL)
|
|
||||||
related_transaction = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL)
|
|
||||||
|
|
||||||
description = models.TextField()
|
|
||||||
notes = models.TextField(blank=True)
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='completed')
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
created_by = models.ForeignKey('accounts.CustomUser', null=True, blank=True, on_delete=models.SET_NULL)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['customer', '-created_at']),
|
|
||||||
models.Index(fields=['transaction_type']),
|
|
||||||
models.Index(fields=['order']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 Новый способ оплаты
|
|
||||||
|
|
||||||
Добавить в команду `create_payment_methods.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
'code': 'account_balance',
|
|
||||||
'name': 'С баланса счета',
|
|
||||||
'description': 'Списание с личного счета клиента',
|
|
||||||
'is_system': True,
|
|
||||||
'order': 0 # Первый в списке
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Бизнес-логика: AccountBalanceService
|
|
||||||
|
|
||||||
**Новый файл**: `myproject/customers/services/account_balance_service.py`
|
|
||||||
|
|
||||||
Создать сервис с методами:
|
|
||||||
|
|
||||||
### Основные методы:
|
|
||||||
|
|
||||||
1. **`deposit(customer, amount, description, user, notes)`**
|
|
||||||
- Пополнение счета вручную администратором
|
|
||||||
- Увеличивает `account_balance` и `available_balance`
|
|
||||||
- Создает транзакцию типа `deposit`
|
|
||||||
|
|
||||||
2. **`auto_deposit_from_overpayment(order, overpayment_amount, user)`**
|
|
||||||
- Автоматическое пополнение при переплате
|
|
||||||
- Вызывается когда `order.amount_paid > order.total_amount`
|
|
||||||
- Создает транзакцию типа `auto_deposit`
|
|
||||||
|
|
||||||
3. **`reserve_balance(customer, order, amount, user)`**
|
|
||||||
- Резервирование средств при создании заказа с оплатой со счета
|
|
||||||
- Проверяет достаточность средств (с учетом кредита)
|
|
||||||
- Уменьшает `available_balance`, увеличивает `reserved_balance`
|
|
||||||
- Создает транзакцию типа `reservation` со статусом `active`
|
|
||||||
|
|
||||||
4. **`charge_reserved_balance(reservation_transaction, user)`**
|
|
||||||
- Списание зарезервированных средств при завершении заказа
|
|
||||||
- Уменьшает `account_balance` и `reserved_balance`
|
|
||||||
- Обновляет статус резервирования на `completed`
|
|
||||||
- Создает транзакцию типа `charge`
|
|
||||||
|
|
||||||
5. **`release_reservation(reservation_transaction, user)`**
|
|
||||||
- Снятие резервирования при отмене заказа
|
|
||||||
- Увеличивает `available_balance`, уменьшает `reserved_balance`
|
|
||||||
- Обновляет статус резервирования на `cancelled`
|
|
||||||
- Создает транзакцию типа `reservation_release`
|
|
||||||
|
|
||||||
6. **`refund(customer, amount, order, description, user, notes)`**
|
|
||||||
- Возврат средств на счет
|
|
||||||
- Используется при индивидуальных решениях по возвратам
|
|
||||||
- Создает транзакцию типа `refund`
|
|
||||||
|
|
||||||
7. **`adjustment(customer, amount, description, user, notes)`**
|
|
||||||
- Корректировка баланса администратором
|
|
||||||
- Может быть положительной или отрицательной
|
|
||||||
- Требует обязательное описание
|
|
||||||
|
|
||||||
### Ключевые особенности реализации:
|
|
||||||
|
|
||||||
- Все методы используют `@transaction.atomic` для атомарности
|
|
||||||
- `select_for_update()` для блокировки записи клиента при изменении
|
|
||||||
- Проверка лимитов кредита перед резервированием
|
|
||||||
- Запись `balance_before` и `balance_after` для аудита
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Интеграция с существующей системой платежей
|
|
||||||
|
|
||||||
### 3.1 Модификация Payment.save()
|
|
||||||
|
|
||||||
**Файл**: `myproject/orders/models/payment.py`
|
|
||||||
|
|
||||||
В методе `save()` добавить логику:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
is_new = self.pk is None
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
# Пересчитываем сумму оплаты
|
|
||||||
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
|
||||||
|
|
||||||
# Обработка оплаты с баланса счета
|
|
||||||
if self.payment_method.code == 'account_balance' and is_new:
|
|
||||||
from customers.services.account_balance_service import AccountBalanceService
|
|
||||||
AccountBalanceService.reserve_balance(
|
|
||||||
customer=self.order.customer,
|
|
||||||
order=self.order,
|
|
||||||
amount=self.amount,
|
|
||||||
user=self.created_by
|
|
||||||
)
|
|
||||||
|
|
||||||
self.order.update_payment_status()
|
|
||||||
|
|
||||||
# Проверка переплаты
|
|
||||||
if self.order.amount_paid > self.order.total_amount:
|
|
||||||
overpayment = self.order.amount_paid - self.order.total_amount
|
|
||||||
from customers.services.account_balance_service import AccountBalanceService
|
|
||||||
AccountBalanceService.auto_deposit_from_overpayment(
|
|
||||||
order=self.order,
|
|
||||||
overpayment_amount=overpayment,
|
|
||||||
user=self.created_by
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Обработка изменения статуса заказа
|
|
||||||
|
|
||||||
**Новый файл**: `myproject/orders/signals.py`
|
|
||||||
|
|
||||||
Создать сигналы для автоматической обработки:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.db.models.signals import post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from .models import Order
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Order)
|
|
||||||
def handle_order_status_change(sender, instance, created, **kwargs):
|
|
||||||
"""Обработка изменения статуса заказа"""
|
|
||||||
if created or not instance.status:
|
|
||||||
return
|
|
||||||
|
|
||||||
from customers.models import AccountTransaction
|
|
||||||
from customers.services.account_balance_service import AccountBalanceService
|
|
||||||
|
|
||||||
# Заказ выполнен успешно - списываем
|
|
||||||
if instance.status.is_positive_end:
|
|
||||||
reservations = AccountTransaction.objects.filter(
|
|
||||||
order=instance,
|
|
||||||
transaction_type='reservation',
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
for reservation in reservations:
|
|
||||||
AccountBalanceService.charge_reserved_balance(
|
|
||||||
reservation_transaction=reservation,
|
|
||||||
user=instance.modified_by
|
|
||||||
)
|
|
||||||
|
|
||||||
# Заказ отменен - снимаем резерв
|
|
||||||
elif instance.status.is_negative_end:
|
|
||||||
reservations = AccountTransaction.objects.filter(
|
|
||||||
order=instance,
|
|
||||||
transaction_type='reservation',
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
for reservation in reservations:
|
|
||||||
AccountBalanceService.release_reservation(
|
|
||||||
reservation_transaction=reservation,
|
|
||||||
user=instance.modified_by
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Подключить сигналы в `myproject/orders/apps.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class OrdersConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'orders'
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
import orders.signals # noqa
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Административный интерфейс
|
|
||||||
|
|
||||||
### 4.1 Расширение CustomerAdmin
|
|
||||||
|
|
||||||
**Файл**: `myproject/customers/admin.py`
|
|
||||||
|
|
||||||
Изменения:
|
|
||||||
|
|
||||||
1. **Добавить поля баланса в list_display**:
|
|
||||||
```python
|
|
||||||
list_display = (
|
|
||||||
'full_name', 'email', 'phone',
|
|
||||||
'account_balance_colored', # новое
|
|
||||||
'available_balance_display', # новое
|
|
||||||
'reserved_balance_display', # новое
|
|
||||||
'total_spent',
|
|
||||||
'is_system_customer',
|
|
||||||
'created_at'
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Добавить фильтр по кредиту**:
|
|
||||||
```python
|
|
||||||
list_filter = (
|
|
||||||
IsSystemCustomerFilter,
|
|
||||||
'allow_negative_balance', # новое
|
|
||||||
'created_at'
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Добавить секцию баланса в fieldsets**:
|
|
||||||
```python
|
|
||||||
('Баланс лицевого счета', {
|
|
||||||
'fields': (
|
|
||||||
'account_balance',
|
|
||||||
'available_balance',
|
|
||||||
'reserved_balance',
|
|
||||||
'allow_negative_balance',
|
|
||||||
'negative_balance_limit',
|
|
||||||
),
|
|
||||||
'classes': ('wide',),
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Добавить inline для транзакций**:
|
|
||||||
```python
|
|
||||||
class AccountTransactionInline(admin.TabularInline):
|
|
||||||
model = AccountTransaction
|
|
||||||
extra = 0
|
|
||||||
can_delete = False
|
|
||||||
readonly_fields = [...]
|
|
||||||
|
|
||||||
inlines = [AccountTransactionInline]
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Добавить actions**:
|
|
||||||
```python
|
|
||||||
actions = [
|
|
||||||
'add_deposit',
|
|
||||||
'add_refund',
|
|
||||||
'add_adjustment',
|
|
||||||
'enable_negative_balance',
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Новый AccountTransactionAdmin
|
|
||||||
|
|
||||||
**Файл**: `myproject/customers/admin.py`
|
|
||||||
|
|
||||||
Создать отдельную админку для просмотра всех транзакций:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@admin.register(AccountTransaction)
|
|
||||||
class AccountTransactionAdmin(admin.ModelAdmin):
|
|
||||||
list_display = [
|
|
||||||
'created_at', 'customer_link', 'transaction_type',
|
|
||||||
'amount_colored', 'balance_after', 'order_link', 'status'
|
|
||||||
]
|
|
||||||
list_filter = ['transaction_type', 'status', 'created_at']
|
|
||||||
search_fields = ['customer__name', 'customer__email', 'description']
|
|
||||||
readonly_fields = [все поля]
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
|
||||||
return False # Только через сервис
|
|
||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
|
||||||
return False # Аудит, нельзя удалять
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 Кастомные views для операций
|
|
||||||
|
|
||||||
**Новый файл**: `myproject/customers/admin_views.py`
|
|
||||||
|
|
||||||
Создать views для:
|
|
||||||
- Пополнения баланса (`/admin/customers/deposit/`)
|
|
||||||
- Возврата средств (`/admin/customers/refund/`)
|
|
||||||
- Корректировки (`/admin/customers/adjustment/`)
|
|
||||||
|
|
||||||
**Новый файл**: `myproject/customers/admin_urls.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.urls import path
|
|
||||||
from . import admin_views
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('deposit/', admin_views.deposit_view, name='customer_deposit'),
|
|
||||||
path('refund/', admin_views.refund_view, name='customer_refund'),
|
|
||||||
path('adjustment/', admin_views.adjustment_view, name='customer_adjustment'),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Подключить в основной `urls.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Формы для операций
|
|
||||||
|
|
||||||
**Новый файл**: `myproject/customers/forms.py`
|
|
||||||
|
|
||||||
Создать формы:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class DepositForm(forms.Form):
|
|
||||||
"""Форма пополнения баланса"""
|
|
||||||
customer = forms.ModelChoiceField(queryset=Customer.objects.all())
|
|
||||||
amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2)
|
|
||||||
description = forms.CharField(widget=forms.Textarea)
|
|
||||||
notes = forms.CharField(widget=forms.Textarea, required=False)
|
|
||||||
|
|
||||||
class RefundForm(forms.Form):
|
|
||||||
"""Форма возврата средств"""
|
|
||||||
customer = forms.ModelChoiceField(queryset=Customer.objects.all())
|
|
||||||
amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2)
|
|
||||||
order = forms.ModelChoiceField(queryset=Order.objects.all(), required=False)
|
|
||||||
description = forms.CharField(widget=forms.Textarea)
|
|
||||||
notes = forms.CharField(widget=forms.Textarea, required=False)
|
|
||||||
|
|
||||||
class AdjustmentForm(forms.Form):
|
|
||||||
"""Форма корректировки баланса"""
|
|
||||||
customer = forms.ModelChoiceField(queryset=Customer.objects.all())
|
|
||||||
amount = forms.DecimalField(max_digits=10, decimal_places=2) # может быть отрицательным
|
|
||||||
description = forms.CharField(widget=forms.Textarea)
|
|
||||||
notes = forms.CharField(widget=forms.Textarea, required=False)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. UI/UX улучшения
|
|
||||||
|
|
||||||
### 6.1 Отображение баланса в форме заказа
|
|
||||||
|
|
||||||
**Файл**: `myproject/orders/templates/orders/order_form.html`
|
|
||||||
|
|
||||||
Добавить блок с информацией о балансе клиента:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% if order.customer %}
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h5>Баланс клиента</h5>
|
|
||||||
<ul>
|
|
||||||
<li>Общий баланс: <strong>{{ order.customer.account_balance }} руб.</strong></li>
|
|
||||||
<li>Доступно: <strong>{{ order.customer.available_balance }} руб.</strong></li>
|
|
||||||
<li>Зарезервировано: <strong>{{ order.customer.reserved_balance }} руб.</strong></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Валидация при выборе оплаты со счета
|
|
||||||
|
|
||||||
**Файл**: `myproject/orders/static/orders/js/payment_validation.js`
|
|
||||||
|
|
||||||
Добавить JS-валидацию:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Проверка достаточности средств при выборе оплаты со счета
|
|
||||||
function validateAccountBalance(paymentMethodCode, amount, availableBalance, allowNegative, creditLimit) {
|
|
||||||
if (paymentMethodCode === 'account_balance') {
|
|
||||||
if (amount > availableBalance && !allowNegative) {
|
|
||||||
alert('Недостаточно средств на счете клиента!');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (allowNegative && creditLimit > 0) {
|
|
||||||
let potentialBalance = availableBalance - amount;
|
|
||||||
if (Math.abs(potentialBalance) > creditLimit) {
|
|
||||||
alert('Превышен лимит кредита клиента!');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Миграции
|
|
||||||
|
|
||||||
### Последовательность миграций:
|
|
||||||
|
|
||||||
1. **Добавить поля баланса в Customer**:
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations customers --name add_account_balance_fields
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Создать модель AccountTransaction**:
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations customers --name create_account_transaction_model
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Создать индексы и ограничения**:
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations customers --name add_balance_constraints
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Инициализация данных** (data migration):
|
|
||||||
```python
|
|
||||||
def initialize_customer_balances(apps, schema_editor):
|
|
||||||
Customer = apps.get_model('customers', 'Customer')
|
|
||||||
Customer.objects.all().update(
|
|
||||||
account_balance=0,
|
|
||||||
available_balance=0,
|
|
||||||
reserved_balance=0,
|
|
||||||
allow_negative_balance=False,
|
|
||||||
negative_balance_limit=0
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Добавить способ оплаты**:
|
|
||||||
```bash
|
|
||||||
python manage.py create_payment_methods
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Обеспечение целостности данных
|
|
||||||
|
|
||||||
### 8.1 Транзакции и блокировки
|
|
||||||
|
|
||||||
- Все операции в `@transaction.atomic`
|
|
||||||
- Использование `select_for_update()` для блокировки записи клиента
|
|
||||||
- Проверка статуса транзакции перед обработкой
|
|
||||||
|
|
||||||
### 8.2 Ограничения БД
|
|
||||||
|
|
||||||
```python
|
|
||||||
# В миграции
|
|
||||||
models.CheckConstraint(
|
|
||||||
check=models.Q(reserved_balance__gte=0),
|
|
||||||
name='reserved_balance_non_negative'
|
|
||||||
)
|
|
||||||
|
|
||||||
models.CheckConstraint(
|
|
||||||
check=models.Q(account_balance__gte=models.F('reserved_balance') * -1),
|
|
||||||
name='available_balance_consistency'
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 Предотвращение дублирования
|
|
||||||
|
|
||||||
- Проверка `status='active'` перед обработкой резервирования
|
|
||||||
- Связь `related_transaction` для отслеживания цепочки операций
|
|
||||||
- Валидация перед созданием транзакции
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Типы транзакций: Подробное описание
|
|
||||||
|
|
||||||
### DEPOSIT (Пополнение)
|
|
||||||
- **Когда**: Администратор вручную пополняет счет
|
|
||||||
- **Эффект**: `account_balance ↑`, `available_balance ↑`
|
|
||||||
- **Статус**: `completed`
|
|
||||||
|
|
||||||
### AUTO_DEPOSIT (Авто-пополнение)
|
|
||||||
- **Когда**: `order.amount_paid > order.total_amount`
|
|
||||||
- **Эффект**: `account_balance ↑`, `available_balance ↑`
|
|
||||||
- **Статус**: `completed`
|
|
||||||
|
|
||||||
### RESERVATION (Резервирование)
|
|
||||||
- **Когда**: Создание заказа с оплатой со счета
|
|
||||||
- **Эффект**: `available_balance ↓`, `reserved_balance ↑`
|
|
||||||
- **Статус**: `active` → меняется при charge/release
|
|
||||||
|
|
||||||
### CHARGE (Списание)
|
|
||||||
- **Когда**: Заказ выполнен (`is_positive_end=True`)
|
|
||||||
- **Эффект**: `account_balance ↓`, `reserved_balance ↓`
|
|
||||||
- **Статус**: `completed`
|
|
||||||
|
|
||||||
### RESERVATION_RELEASE (Снятие резерва)
|
|
||||||
- **Когда**: Заказ отменен (`is_negative_end=True`)
|
|
||||||
- **Эффект**: `available_balance ↑`, `reserved_balance ↓`
|
|
||||||
- **Статус**: `completed`
|
|
||||||
|
|
||||||
### REFUND (Возврат)
|
|
||||||
- **Когда**: Администратор принимает решение о возврате
|
|
||||||
- **Эффект**: `account_balance ↑`, `available_balance ↑`
|
|
||||||
- **Статус**: `completed`
|
|
||||||
|
|
||||||
### ADJUSTMENT (Корректировка)
|
|
||||||
- **Когда**: Ручная корректировка администратором
|
|
||||||
- **Эффект**: `account_balance ±`, `available_balance ±`
|
|
||||||
- **Статус**: `completed`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Сценарии использования
|
|
||||||
|
|
||||||
### Сценарий 1: Заказ с полной оплатой со счета
|
|
||||||
|
|
||||||
1. Клиент создает заказ на 540 руб.
|
|
||||||
2. На балансе 1000 руб.
|
|
||||||
3. Выбирается способ оплаты "С баланса счета"
|
|
||||||
4. **Создается RESERVATION** на 540 руб.: `available_balance: 1000→460`, `reserved_balance: 0→540`
|
|
||||||
5. При выполнении заказа создается **CHARGE**: `account_balance: 1000→460`, `reserved_balance: 540→0`
|
|
||||||
|
|
||||||
### Сценарий 2: Смешанная оплата
|
|
||||||
|
|
||||||
1. Заказ на 540 руб.
|
|
||||||
2. На балансе 300 руб.
|
|
||||||
3. Создается Payment со счета на 300 руб. → **RESERVATION** 300 руб.
|
|
||||||
4. Создается Payment наличными на 240 руб.
|
|
||||||
5. При выполнении → **CHARGE** 300 руб. со счета
|
|
||||||
|
|
||||||
### Сценарий 3: Переплата с авто-пополнением
|
|
||||||
|
|
||||||
1. Заказ на 540 руб.
|
|
||||||
2. Клиент платит наличными 1000 руб.
|
|
||||||
3. `order.amount_paid = 1000`, `order.total_amount = 540`
|
|
||||||
4. Система создает **AUTO_DEPOSIT** на 460 руб.
|
|
||||||
5. Баланс клиента увеличивается на 460 руб.
|
|
||||||
|
|
||||||
### Сценарий 4: Отмена заказа
|
|
||||||
|
|
||||||
1. Заказ на 540 руб. с резервированием
|
|
||||||
2. `reserved_balance = 540`, `available_balance = 460`
|
|
||||||
3. Заказ меняет статус на "Отменен" (`is_negative_end=True`)
|
|
||||||
4. Сигнал создает **RESERVATION_RELEASE**
|
|
||||||
5. `available_balance: 460→1000`, `reserved_balance: 540→0`
|
|
||||||
|
|
||||||
### Сценарий 5: Кредит доверенного клиента
|
|
||||||
|
|
||||||
1. У клиента баланс 0 руб., но `allow_negative_balance=True`
|
|
||||||
2. Заказ на 540 руб.
|
|
||||||
3. Создается **RESERVATION** на 540 руб.
|
|
||||||
4. `account_balance: 0→0`, `available_balance: 0→-540`, `reserved_balance: 0→540`
|
|
||||||
5. При выполнении **CHARGE**: `account_balance: 0→-540`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Тестирование
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
**Файл**: `myproject/customers/tests/test_account_balance_service.py`
|
|
||||||
|
|
||||||
Тесты:
|
|
||||||
- `test_deposit_increases_balance`
|
|
||||||
- `test_reserve_decreases_available`
|
|
||||||
- `test_charge_decreases_account_balance`
|
|
||||||
- `test_release_increases_available`
|
|
||||||
- `test_overpayment_creates_auto_deposit`
|
|
||||||
- `test_negative_balance_validation`
|
|
||||||
- `test_credit_limit_enforcement`
|
|
||||||
- `test_concurrent_operations`
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
**Файл**: `myproject/orders/tests/test_order_with_account_balance.py`
|
|
||||||
|
|
||||||
Тесты:
|
|
||||||
- `test_order_with_account_payment`
|
|
||||||
- `test_mixed_payment_scenario`
|
|
||||||
- `test_order_completion_charges_balance`
|
|
||||||
- `test_order_cancellation_releases_reservation`
|
|
||||||
- `test_overpayment_auto_deposit`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Критические файлы для реализации
|
|
||||||
|
|
||||||
1. **`myproject/customers/models.py`**
|
|
||||||
- Добавить поля баланса в Customer
|
|
||||||
- Создать модель AccountTransaction
|
|
||||||
|
|
||||||
2. **`myproject/customers/services/account_balance_service.py`** (НОВЫЙ)
|
|
||||||
- Все методы управления балансом
|
|
||||||
|
|
||||||
3. **`myproject/orders/models/payment.py`**
|
|
||||||
- Модифицировать `save()` для обработки оплаты со счета
|
|
||||||
|
|
||||||
4. **`myproject/orders/signals.py`** (НОВЫЙ)
|
|
||||||
- Обработка изменения статуса заказа
|
|
||||||
|
|
||||||
5. **`myproject/customers/admin.py`**
|
|
||||||
- Расширить CustomerAdmin
|
|
||||||
- Создать AccountTransactionAdmin
|
|
||||||
|
|
||||||
6. **`myproject/customers/admin_views.py`** (НОВЫЙ)
|
|
||||||
- Views для пополнения/возврата/корректировки
|
|
||||||
|
|
||||||
7. **`myproject/customers/forms.py`** (НОВЫЙ)
|
|
||||||
- Формы для операций с балансом
|
|
||||||
|
|
||||||
8. **`myproject/orders/management/commands/create_payment_methods.py`**
|
|
||||||
- Добавить способ оплаты 'account_balance'
|
|
||||||
|
|
||||||
9. **`myproject/orders/apps.py`**
|
|
||||||
- Подключить сигналы
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Последовательность реализации
|
|
||||||
|
|
||||||
### Фаза 1: Модели и миграции (основа)
|
|
||||||
1. Добавить поля в Customer
|
|
||||||
2. Создать AccountTransaction
|
|
||||||
3. Создать миграции
|
|
||||||
4. Инициализировать данные
|
|
||||||
|
|
||||||
### Фаза 2: Бизнес-логика (ядро)
|
|
||||||
1. Создать AccountBalanceService со всеми методами
|
|
||||||
2. Покрыть unit-тестами
|
|
||||||
|
|
||||||
### Фаза 3: Интеграция с заказами (связывание)
|
|
||||||
1. Модифицировать Payment.save()
|
|
||||||
2. Создать signals.py
|
|
||||||
3. Добавить способ оплаты
|
|
||||||
4. Покрыть integration-тестами
|
|
||||||
|
|
||||||
### Фаза 4: Административный интерфейс (управление)
|
|
||||||
1. Расширить CustomerAdmin
|
|
||||||
2. Создать AccountTransactionAdmin
|
|
||||||
3. Создать формы и views для операций
|
|
||||||
4. Настроить URLs
|
|
||||||
|
|
||||||
### Фаза 5: UI/UX улучшения (удобство)
|
|
||||||
1. Отображение баланса в форме заказа
|
|
||||||
2. JS-валидация при оплате
|
|
||||||
3. Виджеты истории транзакций
|
|
||||||
|
|
||||||
### Фаза 6: Тестирование и документация (качество)
|
|
||||||
1. Полное покрытие тестами
|
|
||||||
2. Ручное тестирование сценариев
|
|
||||||
3. Документация для администраторов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Безопасность и права доступа
|
|
||||||
|
|
||||||
- Только `staff_member_required` для admin views
|
|
||||||
- Транзакции нельзя удалять (`has_delete_permission = False`)
|
|
||||||
- Транзакции нельзя создавать вручную (`has_add_permission = False`)
|
|
||||||
- Все операции требуют `created_by` (аудит)
|
|
||||||
- Mandatory `description` для adjustment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Заключение
|
|
||||||
|
|
||||||
Данная архитектура обеспечивает:
|
|
||||||
- ✅ Полную историю операций (аудит)
|
|
||||||
- ✅ Атомарность операций (транзакции БД)
|
|
||||||
- ✅ Защиту от race conditions (блокировки)
|
|
||||||
- ✅ Гибкость (смешанная оплата, кредит)
|
|
||||||
- ✅ Интеграцию с существующей системой
|
|
||||||
- ✅ Простоту управления (admin interface)
|
|
||||||
- ✅ Безопасность (только администраторы)
|
|
||||||
|
|
||||||
Решение готово к production-использованию после прохождения всех фаз тестирования.
|
|
||||||
25
ЗАПУСК.md
25
ЗАПУСК.md
@@ -1,25 +0,0 @@
|
|||||||
# Запуск системы
|
|
||||||
|
|
||||||
## Из каталога `(venv) PS C:\Users\team_\Desktop\test_qwen\`
|
|
||||||
|
|
||||||
### 1. Запуск Celery Worker (для фото и прочих задач)
|
|
||||||
```powershell
|
|
||||||
start_celery.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Запуск Celery Beat (для периодических задач)
|
|
||||||
**Новая команда - для автоматической очистки блокировок корзины каждые 5 минут:**
|
|
||||||
```powershell из myproject
|
|
||||||
celery -A myproject beat -l info
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что делает Celery Beat?
|
|
||||||
- Автоматически освобождает витринные комплекты, если блокировка в корзине истекла (30 минут)
|
|
||||||
- Запускает задачу `cleanup_expired_cart_locks` каждые 5 минут
|
|
||||||
- Логи пишет в консоль (уровень INFO)
|
|
||||||
|
|
||||||
## Можно ли не запускать Beat?
|
|
||||||
Да, но тогда блокировки не будут автоматически сниматься при истечении таймаута.
|
|
||||||
Кассиры все равно смогут работать, просто букет останется "занят" до ручного удаления из корзины.
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
# Удаление Тенантов в Django-Tenants
|
|
||||||
|
|
||||||
## ⚠️ КРИТИЧЕСКИ ВАЖНО
|
|
||||||
|
|
||||||
**В этом проекте `auto_drop_schema = False`, поэтому ВСЕ команды удаления (включая `cleanup_tenant` и `delete_tenant`) НЕ удаляют схему из PostgreSQL автоматически!**
|
|
||||||
|
|
||||||
После удаления тенанта через любую команду **ОБЯЗАТЕЛЬНО** нужно вручную удалить схему из базы данных, иначе:
|
|
||||||
- ✅ Запись `Client` удалится
|
|
||||||
- ❌ Схема со всеми таблицами и данными останется в PostgreSQL
|
|
||||||
- ❌ При повторной регистрации с тем же именем новый тенант увидит старые данные!
|
|
||||||
|
|
||||||
### Как удалить схему вручную:
|
|
||||||
|
|
||||||
**Вариант 1 — через Django shell:**
|
|
||||||
```python
|
|
||||||
from django.db import connection
|
|
||||||
|
|
||||||
with connection.cursor() as cursor:
|
|
||||||
cursor.execute('DROP SCHEMA IF EXISTS имя_схемы CASCADE;')
|
|
||||||
```
|
|
||||||
|
|
||||||
**Вариант 2 — одной командой из консоли:**
|
|
||||||
```bash
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS имя_схемы CASCADE;')"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Вариант 3 — напрямую в PostgreSQL (psql):**
|
|
||||||
```sql
|
|
||||||
DROP SCHEMA IF EXISTS имя_схемы CASCADE;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Быстрая справка
|
|
||||||
|
|
||||||
### Рекомендуемый способ (с улучшенной командой):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Базовое удаление (Client + файлы, заявка остается в истории)
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput
|
|
||||||
|
|
||||||
# Полная очистка (Client + заявка + файлы)
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
|
|
||||||
|
|
||||||
# ЗАТЕМ ВРУЧНУЮ удалить схему из PostgreSQL:
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Альтернативный способ (встроенная команда django-tenants):
|
|
||||||
|
|
||||||
⚠️ **ВНИМАНИЕ:** В этом проекте `auto_drop_schema = False`, поэтому команда `delete_tenant` **НЕ удаляет схему из PostgreSQL**. Удаляется только запись `Client`, но все таблицы и данные в схеме остаются в базе. Для полного удаления используйте `cleanup_tenant` (см. выше) или удалите схему вручную.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Удалить конкретного тенанта (только запись Client, схема БД остаётся!)
|
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
|
||||||
|
|
||||||
# Удалить все файлы тенанта (после удаления из БД)
|
|
||||||
Remove-Item -Path 'media/tenants' -Recurse -Force
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Подробное руководство
|
|
||||||
|
|
||||||
### ⭐ Способ 0: Новая улучшенная команда cleanup_tenant (РЕКОМЕНДУЕТСЯ)
|
|
||||||
|
|
||||||
**Эта команда решает проблему с TenantRegistration и управляет связанными данными**
|
|
||||||
|
|
||||||
#### Что это за команда?
|
|
||||||
|
|
||||||
Это новая management команда, которая автоматически:
|
|
||||||
- Удаляет запись Client из таблицы тенантов
|
|
||||||
- Обрабатывает TenantRegistration (может оставить в истории или удалить)
|
|
||||||
- Опционально удаляет физические файлы
|
|
||||||
- Показывает красивый прогресс с подтверждением
|
|
||||||
|
|
||||||
⚠️ **ВАЖНО:** Команда **НЕ удаляет схему PostgreSQL** (т.к. `auto_drop_schema = False`). После выполнения команды нужно вручную удалить схему через SQL (см. раздел выше).
|
|
||||||
|
|
||||||
#### Параметры:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--schema=<имя> # Имя тенанта (обязательно)
|
|
||||||
--noinput # Не запрашивать подтверждение
|
|
||||||
--purge-registration # Удалить TenantRegistration (иначе оставляет с tenant=NULL)
|
|
||||||
--delete-files # Удалить физические файлы из /media/tenants/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Варианты использования:
|
|
||||||
|
|
||||||
**1️⃣ Базовое удаление (рекомендуется):**
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput
|
|
||||||
# ЗАТЕМ вручную:
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
|
||||||
```
|
|
||||||
Удаляет: Client (заявка остается в истории с tenant=NULL)
|
|
||||||
Требует: Ручное удаление схемы PostgreSQL
|
|
||||||
|
|
||||||
**2️⃣ Полная очистка:**
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
|
|
||||||
# ЗАТЕМ вручную:
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
|
||||||
```
|
|
||||||
Удаляет: Client + заявка + файлы
|
|
||||||
Требует: Ручное удаление схемы PostgreSQL
|
|
||||||
|
|
||||||
**3️⃣ Удаление с заявкой:**
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration
|
|
||||||
# ЗАТЕМ вручную:
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
|
||||||
```
|
|
||||||
Удаляет: Client + заявка (файлы остаются)
|
|
||||||
Требует: Ручное удаление схемы PostgreSQL
|
|
||||||
|
|
||||||
#### Пример вывода:
|
|
||||||
|
|
||||||
```
|
|
||||||
=== Удаление тенанта (магазина) ===
|
|
||||||
|
|
||||||
======================================================================
|
|
||||||
ВНИМАНИЕ! Будут удалены следующие данные:
|
|
||||||
======================================================================
|
|
||||||
|
|
||||||
📋 Тенант:
|
|
||||||
• Название: Papa Shop
|
|
||||||
• Schema: papa
|
|
||||||
• Владелец: admin@example.com
|
|
||||||
|
|
||||||
💾 База данных:
|
|
||||||
• Запись Client "papa" будет удалена
|
|
||||||
• ⚠️ Схема PostgreSQL останется и требует ручного удаления!
|
|
||||||
|
|
||||||
📝 TenantRegistration:
|
|
||||||
• Заявка от Papa Owner (papa@example.com)
|
|
||||||
• Статус: Одобрено
|
|
||||||
• Действие: оставить с tenant=NULL (сохранить историю)
|
|
||||||
|
|
||||||
======================================================================
|
|
||||||
▶ Начинаю удаление...
|
|
||||||
======================================================================
|
|
||||||
|
|
||||||
1️⃣ Обновляю TenantRegistration (сохраняю историю)...
|
|
||||||
✓ TenantRegistration обновлена (tenant=NULL)
|
|
||||||
2️⃣ Удаляю Client...
|
|
||||||
✓ Client "Papa Shop" удален
|
|
||||||
⚠️ Схема БД "papa" НЕ удалена - требует ручного удаления!
|
|
||||||
|
|
||||||
======================================================================
|
|
||||||
✓ Тенант успешно удален!
|
|
||||||
======================================================================
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Способ 1: Удаление одного тенанта по schema
|
|
||||||
|
|
||||||
**Команда:**
|
|
||||||
```bash
|
|
||||||
cd myproject
|
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
|
||||||
```
|
|
||||||
|
|
||||||
**Параметры:**
|
|
||||||
- `--schema=papa` - Удалить тенант с именем schema `papa`
|
|
||||||
- `--noinput` - Не запрашивать подтверждение (автоматический режим)
|
|
||||||
|
|
||||||
**Результат:**
|
|
||||||
```
|
|
||||||
Deleting 'papa'
|
|
||||||
Deleted 'papa'
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ **ВНИМАНИЕ:** В этом проекте `auto_drop_schema = False`, поэтому команда `delete_tenant` **НЕ удаляет схему из PostgreSQL**!
|
|
||||||
|
|
||||||
Эта команда удаляет:
|
|
||||||
- ✅ Запись Client из таблицы тенантов
|
|
||||||
- ❌ НЕ удаляет схему PostgreSQL (остаются все таблицы и данные!)
|
|
||||||
- ❌ НЕ удаляет файлы в `/media/tenants/{tenant_id}/`
|
|
||||||
|
|
||||||
После выполнения нужно вручную удалить схему (см. раздел "КРИТИЧЕСКИ ВАЖНО" выше).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Способ 2: Интерактивное удаление (с выбором)
|
|
||||||
|
|
||||||
**Команда:**
|
|
||||||
```bash
|
|
||||||
python manage.py delete_tenant
|
|
||||||
```
|
|
||||||
|
|
||||||
**Результат:**
|
|
||||||
Система запросит у вас:
|
|
||||||
```
|
|
||||||
Enter Tenant Schema ('?' to list schemas): papa
|
|
||||||
Are you sure you want to delete the tenant: papa? (yes/no): yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Введите:
|
|
||||||
- `?` - чтобы увидеть список всех тенантов
|
|
||||||
- Имя schema - чтобы выбрать тенант
|
|
||||||
- `yes` - чтобы подтвердить удаление
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Способ 3: Удаление файлов после удаления из БД
|
|
||||||
|
|
||||||
После удаления тенанта из БД нужно удалить его файлы вручную.
|
|
||||||
|
|
||||||
**На Windows (PowerShell):**
|
|
||||||
```powershell
|
|
||||||
Remove-Item -Path 'c:\Users\team_\Desktop\test_qwen\myproject\media\tenants' -Recurse -Force
|
|
||||||
Write-Host 'Removed tenants directory'
|
|
||||||
```
|
|
||||||
|
|
||||||
**На Windows (CMD):**
|
|
||||||
```cmd
|
|
||||||
rmdir /s /q "c:\Users\team_\Desktop\test_qwen\myproject\media\tenants"
|
|
||||||
```
|
|
||||||
|
|
||||||
**На Linux/Mac:**
|
|
||||||
```bash
|
|
||||||
rm -rf ./media/tenants
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что удаляется?
|
|
||||||
|
|
||||||
### БД (требует РУЧНОГО удаления):
|
|
||||||
|
|
||||||
⚠️ **Схема PostgreSQL НЕ удаляется автоматически** из-за `auto_drop_schema = False`!
|
|
||||||
|
|
||||||
Что остаётся в базе после `cleanup_tenant` или `delete_tenant`:
|
|
||||||
```
|
|
||||||
PostgreSQL schema: papa ← ОСТАЁТСЯ В БАЗЕ!
|
|
||||||
├── products_product
|
|
||||||
├── products_productphoto
|
|
||||||
├── products_productkit
|
|
||||||
├── products_productkitphoto
|
|
||||||
├── products_productcategory
|
|
||||||
├── products_productcategoryphoto
|
|
||||||
├── inventory_*
|
|
||||||
├── orders_*
|
|
||||||
└── ... (все другие таблицы в schema)
|
|
||||||
```
|
|
||||||
|
|
||||||
Для полного удаления выполните:
|
|
||||||
```bash
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Файлы (нужно удалить вручную):
|
|
||||||
```
|
|
||||||
media/tenants/papa/
|
|
||||||
├── products/
|
|
||||||
│ ├── {product_id}/{photo_id}/original.jpg
|
|
||||||
│ ├── {product_id}/{photo_id}/large.webp
|
|
||||||
│ ├── {product_id}/{photo_id}/medium.webp
|
|
||||||
│ ├── {product_id}/{photo_id}/thumb.webp
|
|
||||||
│ └── temp/... (временные файлы)
|
|
||||||
├── kits/
|
|
||||||
├── categories/
|
|
||||||
└── ... (все файлы тенанта)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Полный цикл удаления тенанта
|
|
||||||
|
|
||||||
### 1️⃣ Удалить Client:
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2️⃣ Удалить схему PostgreSQL:
|
|
||||||
```bash
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3️⃣ Проверить удаление схемы (опционально):
|
|
||||||
```powershell
|
|
||||||
Test-Path 'c:\Users\team_\Desktop\test_qwen\myproject\media\tenants'
|
|
||||||
# Должно вернуть: False
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Примеры
|
|
||||||
|
|
||||||
### Пример 1: Удалить тенант "papa"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd myproject
|
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Remove-Item -Path 'media/tenants/papa' -Recurse -Force
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример 2: Удалить все тенанты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Удалить из БД (нужно сделать для каждого тенанта)
|
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
|
||||||
python manage.py delete_tenant --schema=customer1 --noinput
|
|
||||||
python manage.py delete_tenant --schema=test --noinput
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Удалить все файлы
|
|
||||||
Remove-Item -Path 'media/tenants' -Recurse -Force
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример 3: Список доступных тенантов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Интерактивный режим - введите ? для списка
|
|
||||||
python manage.py delete_tenant
|
|
||||||
# Введите: ?
|
|
||||||
# Получите: список всех доступных schemas
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Важные замечания
|
|
||||||
|
|
||||||
⚠️ **ВНИМАНИЕ!**
|
|
||||||
|
|
||||||
- Удаление **необратимо** - нет возможности восстановления
|
|
||||||
- Сначала удаляйте из БД, потом удаляйте файлы
|
|
||||||
- Не забывайте удалять файлы - они занимают место на диске
|
|
||||||
- При удалении всех тенантов БД может остаться в некорректном состоянии
|
|
||||||
|
|
||||||
✅ **РЕКОМЕНДАЦИИ:**
|
|
||||||
|
|
||||||
- Делайте бэкап перед удалением на продакшене
|
|
||||||
- Используйте `--noinput` для автоматизации скриптами
|
|
||||||
- Удаляйте файлы регулярно, чтобы освободить место
|
|
||||||
- Проверяйте результат удаления
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибки и решения
|
|
||||||
|
|
||||||
### Ошибка: "EOFError: EOF when reading a line"
|
|
||||||
|
|
||||||
```
|
|
||||||
Enter Tenant Schema ('?' to list schemas):
|
|
||||||
EOFError: EOF when reading a line
|
|
||||||
```
|
|
||||||
|
|
||||||
**Решение:** Используйте флаг `--schema=`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ошибка: "Tenant doesn't exist"
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Tenant doesn't exist
|
|
||||||
```
|
|
||||||
|
|
||||||
**Решение:** Проверьте точное имя schema
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Посмотрите список тенантов
|
|
||||||
python manage.py delete_tenant
|
|
||||||
# Введите: ?
|
|
||||||
```
|
|
||||||
|
|
||||||
### Файлы не удаляются на Windows
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Если файл заблокирован, закройте все приложения и попробуйте:
|
|
||||||
Remove-Item -Path 'media/tenants' -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Команда для быстрого удаления (скрипт)
|
|
||||||
|
|
||||||
**delete_all_tenants.sh (для Linux/Mac):**
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
|
||||||
python manage.py delete_tenant --schema=test --noinput
|
|
||||||
rm -rf ./media/tenants
|
|
||||||
echo "All tenants deleted"
|
|
||||||
```
|
|
||||||
|
|
||||||
**delete_all_tenants.ps1 (для Windows):**
|
|
||||||
```powershell
|
|
||||||
# Удалить БД
|
|
||||||
python manage.py delete_tenant --schema=papa --noinput
|
|
||||||
python manage.py delete_tenant --schema=test --noinput
|
|
||||||
|
|
||||||
# Удалить файлы
|
|
||||||
Remove-Item -Path 'media/tenants' -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
Write-Host "All tenants deleted successfully"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что делать с TenantRegistration?
|
|
||||||
|
|
||||||
### Проблема
|
|
||||||
|
|
||||||
При удалении Client тенанта остается заявка TenantRegistration со статусом 'approved' и `tenant=NULL`.
|
|
||||||
|
|
||||||
Это создает проблему: если клиент захочет **повторно зарегистрироваться с тем же поддоменом**, система выдаст ошибку:
|
|
||||||
```
|
|
||||||
Error: duplicate key value violates unique constraint 'schema_name'
|
|
||||||
```
|
|
||||||
|
|
||||||
Потому что в таблице TenantRegistration уже есть запись с `schema_name='papa'`.
|
|
||||||
|
|
||||||
### Решение
|
|
||||||
|
|
||||||
**Вариант 1: Оставить заявку в истории (рекомендуется)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput
|
|
||||||
```
|
|
||||||
|
|
||||||
Заявка остается в админке с `tenant=NULL`. Это:
|
|
||||||
- ✅ Сохраняет историю регистраций
|
|
||||||
- ✅ Видна попытка создания магазина (для аналитики)
|
|
||||||
- ❌ Требует ручного удаления старой заявки перед новой регистрацией
|
|
||||||
|
|
||||||
**Если клиент захочет зарегистрироваться снова:**
|
|
||||||
1. Вручную удалить старую TenantRegistration через админку
|
|
||||||
2. Тогда он сможет создать новую заявку с тем же schema_name
|
|
||||||
|
|
||||||
**Вариант 2: Удалить заявку автоматически**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration
|
|
||||||
```
|
|
||||||
|
|
||||||
Удаляет:
|
|
||||||
- ✅ Client
|
|
||||||
- ✅ TenantRegistration (полная очистка)
|
|
||||||
- ❌ Теряется история регистраций
|
|
||||||
- ⚠️ Требует ручного удаления схемы PostgreSQL
|
|
||||||
|
|
||||||
**Если клиент захочет зарегистрироваться снова:**
|
|
||||||
1. Просто заполняет форму регистрации
|
|
||||||
2. Может использовать тот же schema_name
|
|
||||||
3. Все работает как в первый раз
|
|
||||||
|
|
||||||
### Мой совет
|
|
||||||
|
|
||||||
Для **тестового проекта**: используй `--purge-registration` (чище)
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration
|
|
||||||
```
|
|
||||||
|
|
||||||
Для **боевого проекта**: оставляй заявку в истории (для аудита)
|
|
||||||
```bash
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Вопросы и ответы
|
|
||||||
|
|
||||||
**Q: Как посмотреть список тенантов?**
|
|
||||||
A: Введите `?` в интерактивном режиме:
|
|
||||||
```bash
|
|
||||||
python manage.py delete_tenant
|
|
||||||
# Введите: ?
|
|
||||||
```
|
|
||||||
|
|
||||||
**Q: Могу ли я восстановить удаленного тенанта?**
|
|
||||||
A: Нет, удаление необратимо. Только из бэкапа БД.
|
|
||||||
|
|
||||||
**Q: Что если тенант все еще используется?**
|
|
||||||
A: Появится ошибка. Закройте все приложения работающие с БД и попробуйте снова.
|
|
||||||
|
|
||||||
**Q: Как удалить тенант если забыл его schema name?**
|
|
||||||
A: Посмотрите в таблице `django_tenants_tenant`:
|
|
||||||
```bash
|
|
||||||
python manage.py shell
|
|
||||||
# Введите:
|
|
||||||
from django_tenants.models import Client
|
|
||||||
for tenant in Client.objects.all():
|
|
||||||
print(f"{tenant.name} -> {tenant.schema_name}")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ручной контроль удаления схем (PostgreSQL + django-tenants 3.7.0)
|
|
||||||
|
|
||||||
### Что важно знать про django-tenants
|
|
||||||
|
|
||||||
В `django-tenants==3.7.0` удаление тенанта работает так:
|
|
||||||
|
|
||||||
- Тенант удаляется через обычный ORM:
|
|
||||||
```python
|
|
||||||
tenant.delete()
|
|
||||||
```
|
|
||||||
- На модели тенанта (`Client`) есть флаг:
|
|
||||||
```python
|
|
||||||
auto_drop_schema = False # по умолчанию
|
|
||||||
```
|
|
||||||
- Если `auto_drop_schema = False`:
|
|
||||||
- При `tenant.delete()` удаляется только запись в таблице клиентов.
|
|
||||||
- Схема в PostgreSQL (`schema_name`) физически остаётся со всеми таблицами и данными.
|
|
||||||
- Если `auto_drop_schema = True`:
|
|
||||||
- При `tenant.delete()` будет выполнен `DROP SCHEMA <schema_name> CASCADE`.
|
|
||||||
- Это удобно, но ОЧЕНЬ опасно: любое удаление тенанта через ORM (например, через админку) без дополнительных проверок сразу дропает схему.
|
|
||||||
|
|
||||||
### Выбранная стратегия: полный ручной контроль
|
|
||||||
|
|
||||||
Для этого проекта принято решение:
|
|
||||||
|
|
||||||
- В модели `Client` оставить:
|
|
||||||
```python
|
|
||||||
auto_drop_schema = False
|
|
||||||
```
|
|
||||||
- НЕ полагаться на автоматический `auto_drop_schema=True`.
|
|
||||||
- Всегда явно контролировать момент, когда схема в PostgreSQL удаляется.
|
|
||||||
|
|
||||||
Это даёт:
|
|
||||||
|
|
||||||
- ✅ Защиту от случайного дропа схемы через админку или произвольный `.delete()`.
|
|
||||||
- ✅ Прозрачный и предсказуемый процесс: схема дропается только явной SQL-командой.
|
|
||||||
- ✅ Возможность временно сохранить схему для отладки/анализа.
|
|
||||||
- ❌ Требуется дополнительный шаг — ручное удаление схемы после каждого удаления тенанта.
|
|
||||||
|
|
||||||
### Рекомендуемый workflow удаления
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Шаг 1: Удалить Client и связанные данные
|
|
||||||
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
|
|
||||||
|
|
||||||
# Шаг 2: Явно удалить схему PostgreSQL
|
|
||||||
python manage.py shell -c "from django.db import connection; cur = connection.cursor(); cur.execute('DROP SCHEMA IF EXISTS papa CASCADE;')"
|
|
||||||
|
|
||||||
# Или через psql напрямую:
|
|
||||||
# DROP SCHEMA IF EXISTS papa CASCADE;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Практические выводы
|
|
||||||
|
|
||||||
- При удалении тенанта в этом проекте НЕЛЬЗЯ полагаться только на:
|
|
||||||
```bash
|
|
||||||
python manage.py delete_tenant --schema=...
|
|
||||||
```
|
|
||||||
- Рекомендуется ВСЕГДА использовать двухшаговый процесс:
|
|
||||||
1. `cleanup_tenant` для удаления Client, TenantRegistration и файлов
|
|
||||||
2. Явное `DROP SCHEMA ... CASCADE` для удаления схемы PostgreSQL
|
|
||||||
|
|
||||||
- Если потребуется временно оставить схему (например, для отладки), достаточно:
|
|
||||||
- Выполнить только шаг 1 (cleanup_tenant)
|
|
||||||
- Отложить шаг 2 (DROP SCHEMA) на потом
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Дата создания:** 2025-11-23
|
|
||||||
**Дата обновления:** 2025-12-01
|
|
||||||
**Версия:** 2.1
|
|
||||||
**Статус:** Production Ready ✅
|
|
||||||
|
|
||||||
### Что нового в версии 2.1:
|
|
||||||
|
|
||||||
- 🔴 **КРИТИЧЕСКОЕ:** Добавлено предупреждение о необходимости ручного удаления схем PostgreSQL
|
|
||||||
- 📖 Добавлен раздел "Ручной контроль удаления схем" с объяснением стратегии `auto_drop_schema = False`
|
|
||||||
- 📖 Обновлены все примеры команд с указанием необходимости ручного удаления схемы
|
|
||||||
- 📖 Исправлены описания того, что именно удаляют команды `cleanup_tenant` и `delete_tenant`
|
|
||||||
- ✨ Добавлены три способа удаления схемы вручную (Django shell, консоль, psql)
|
|
||||||
|
|
||||||
### Что было в версии 2.0:
|
|
||||||
|
|
||||||
- ✨ Добавлена новая улучшенная команда `cleanup_tenant`
|
|
||||||
- ✨ Команда автоматически обрабатывает TenantRegistration
|
|
||||||
- ✨ Добавлена опция `--purge-registration` для удаления заявок
|
|
||||||
- ✨ Добавлена опция `--delete-files` для удаления физических файлов
|
|
||||||
- 📖 Расширена документация с объяснением проблемы TenantRegistration
|
|
||||||
- 📖 Добавлены примеры использования для разных сценариев
|
|
||||||
Reference in New Issue
Block a user