Удалена вся документация .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:
2025-12-02 15:05:46 +03:00
parent 387f5dfdb4
commit 384f3c22f8
31 changed files with 3 additions and 7947 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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-списание
- ✅ Продажи
**Необходимо протестировать** перед продакшеном!

View File

@@ -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 текста при клике на количество
✅ Логирование для отладки в консоли браузера
✅ Надёжная валидация данных на разных уровнях

View File

@@ -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

View File

@@ -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/

View File

@@ -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
**Статус:** ✅ Готово к продакшену

View File

@@ -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** и готова к использованию.

View File

@@ -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 завершена! 🎉 Система полностью готова к расширению.**

View File

@@ -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

View File

@@ -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*

View File

@@ -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 техник в реальном проекте**

View File

@@ -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

View File

@@ -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 и шаблонах!**

View File

@@ -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

View File

@@ -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. 🔜 Реализуйте биллинг и тарифные планы

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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` установлен
---
**Система готова к использованию!**
Теперь вы можете:
- Принимать заявки на регистрацию
- Модерировать их через админку
- Управлять подписками
- Контролировать доступ к магазинам

View File

@@ -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 секунды

View File

@@ -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

View File

@@ -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 проекта.

View File

@@ -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. Создать отдельный модуль для миксинов и базовых классов

View File

@@ -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-использованию после прохождения всех фаз тестирования.

View File

@@ -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?
Да, но тогда блокировки не будут автоматически сниматься при истечении таймаута.
Кассиры все равно смогут работать, просто букет останется "занят" до ручного удаления из корзины.

View File

@@ -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
- 📖 Добавлены примеры использования для разных сценариев