diff --git a/.claude_notes.md b/.claude_notes.md deleted file mode 100644 index 33b6c33..0000000 --- a/.claude_notes.md +++ /dev/null @@ -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/` → только комплекты diff --git a/.gitignore b/.gitignore index 5d32022..dd996b7 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ IMPLEMENTATION_SUMMARY.md FINAL_REPORT.md start_celery.bat start_celery.sh + +# All markdown files +*.md diff --git a/CARD_INTERFACE_COMPLETION.md b/CARD_INTERFACE_COMPLETION.md deleted file mode 100644 index 62b24f3..0000000 --- a/CARD_INTERFACE_COMPLETION.md +++ /dev/null @@ -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 diff --git a/CONFIGURABLEKIT_IMPLEMENTATION_SUMMARY.md b/CONFIGURABLEKIT_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index e609494..0000000 --- a/CONFIGURABLEKIT_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -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. diff --git a/DOUBLE_SALE_FIX.md b/DOUBLE_SALE_FIX.md deleted file mode 100644 index 83c965a..0000000 --- a/DOUBLE_SALE_FIX.md +++ /dev/null @@ -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 diff --git a/FIX_RESERVATION_DUPLICATE_BUG.md b/FIX_RESERVATION_DUPLICATE_BUG.md deleted file mode 100644 index cb38392..0000000 --- a/FIX_RESERVATION_DUPLICATE_BUG.md +++ /dev/null @@ -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-списание -- ✅ Продажи - -**Необходимо протестировать** перед продакшеном! diff --git a/IMPROVEMENTS_SUMMARY.md b/IMPROVEMENTS_SUMMARY.md deleted file mode 100644 index 65baf69..0000000 --- a/IMPROVEMENTS_SUMMARY.md +++ /dev/null @@ -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 текста при клике на количество -✅ Логирование для отладки в консоли браузера -✅ Надёжная валидация данных на разных уровнях diff --git a/KIT_BINDING_IMPLEMENTATION.md b/KIT_BINDING_IMPLEMENTATION.md deleted file mode 100644 index a8ae802..0000000 --- a/KIT_BINDING_IMPLEMENTATION.md +++ /dev/null @@ -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 - -``` - -### 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 diff --git a/KIT_PRICING_SYSTEM_READY.md b/KIT_PRICING_SYSTEM_READY.md deleted file mode 100644 index 73146cd..0000000 --- a/KIT_PRICING_SYSTEM_READY.md +++ /dev/null @@ -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/ diff --git a/MULTITENANT_PHOTO_FIX.md b/MULTITENANT_PHOTO_FIX.md deleted file mode 100644 index 20b5ad6..0000000 --- a/MULTITENANT_PHOTO_FIX.md +++ /dev/null @@ -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 -**Статус:** ✅ Готово к продакшену diff --git a/PHOTO_QUALITY_SYSTEM_COMPLETE.md b/PHOTO_QUALITY_SYSTEM_COMPLETE.md deleted file mode 100644 index 20862cf..0000000 --- a/PHOTO_QUALITY_SYSTEM_COMPLETE.md +++ /dev/null @@ -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 бейдж: ✓ Отлично""" - -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('Сохраните фото') - - 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('Нет фото') - - # Flexbox контейнер с иконкой и фото - quality_indicator = format_quality_badge(first_photo.quality_level) - return format_html( - '
' - '' - '{}' - '
', - 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 %} - - -
-
- - {% quality_indicator photo %} -
-
- ... - {{ photo|quality_badge_full }} -
-
- - - -``` - -**JavaScript для галереи:** -```javascript -photoCarousel.addEventListener('slid.bs.carousel', function(event) { - const photoInfo = photos[event.to]; - - // Обновляем статус качества при смене слайда - qualityStatusEl.innerHTML = - ` - ${info.symbol} ${info.label} (${width}×${height}px) - `; -}); -``` - -#### product_list.html - -```django -{% load quality_tags %} - -
- - {{ photo|quality_icon_only }} -
-``` - -Показывает маленький значок (🟢/🟡/🟠/🔴/⚠️) в углу миниатюры. - -#### productkit_detail.html - -```django -{% load quality_tags %} - -
-
- - {% quality_indicator photo %} -
- -
-``` - ---- - -## Файлы проекта - -### Новые файлы - -| Файл | Описание | -|------|---------| -| `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** и готова к использованию. diff --git a/PHOTO_QUALITY_SYSTEM_PHASE1.md b/PHOTO_QUALITY_SYSTEM_PHASE1.md deleted file mode 100644 index 536e2fc..0000000 --- a/PHOTO_QUALITY_SYSTEM_PHASE1.md +++ /dev/null @@ -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 завершена! 🎉 Система полностью готова к расширению.** diff --git a/RESERVATION_FIX.md b/RESERVATION_FIX.md deleted file mode 100644 index 2197549..0000000 --- a/RESERVATION_FIX.md +++ /dev/null @@ -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 diff --git a/STATUS_RACE_CONDITION_FIX.md b/STATUS_RACE_CONDITION_FIX.md deleted file mode 100644 index ff590fc..0000000 --- a/STATUS_RACE_CONDITION_FIX.md +++ /dev/null @@ -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* diff --git a/TECHNICAL_RACE_CONDITION_FIX.md b/TECHNICAL_RACE_CONDITION_FIX.md deleted file mode 100644 index bc022c3..0000000 --- a/TECHNICAL_RACE_CONDITION_FIX.md +++ /dev/null @@ -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 техник в реальном проекте** diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md deleted file mode 100644 index c64001b..0000000 --- a/TESTING_GUIDE.md +++ /dev/null @@ -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 diff --git a/VARIANT_STOCK_IMPLEMENTATION.md b/VARIANT_STOCK_IMPLEMENTATION.md deleted file mode 100644 index 548d575..0000000 --- a/VARIANT_STOCK_IMPLEMENTATION.md +++ /dev/null @@ -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 }} -{{ variant_group.price }} -``` - ---- - -## 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 %} -
-

{{ variant_group.name }}

- - {% if variant_group.in_stock %} - В наличии - {% else %} - Нет в наличии - {% endif %} - -
- Цена: {{ variant_group.price }} руб -
- - -
-{% 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 и шаблонах!** diff --git a/myproject/COST_PRICE_QUICK_GUIDE.md b/myproject/COST_PRICE_QUICK_GUIDE.md deleted file mode 100644 index 961e947..0000000 --- a/myproject/COST_PRICE_QUICK_GUIDE.md +++ /dev/null @@ -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 diff --git a/myproject/DJANGO_TENANTS_SETUP.md b/myproject/DJANGO_TENANTS_SETUP.md deleted file mode 100644 index 7a73595..0000000 --- a/myproject/DJANGO_TENANTS_SETUP.md +++ /dev/null @@ -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 --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. 🔜 Реализуйте биллинг и тарифные планы diff --git a/myproject/QUICK_START.md b/myproject/QUICK_START.md deleted file mode 100644 index fa29246..0000000 --- a/myproject/QUICK_START.md +++ /dev/null @@ -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) diff --git a/myproject/START_FRESH.md b/myproject/START_FRESH.md deleted file mode 100644 index 543bed7..0000000 --- a/myproject/START_FRESH.md +++ /dev/null @@ -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 diff --git a/myproject/START_HERE.md b/myproject/START_HERE.md deleted file mode 100644 index 3267626..0000000 --- a/myproject/START_HERE.md +++ /dev/null @@ -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 --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) diff --git a/myproject/TENANT_ADMIN_GUIDE.md b/myproject/TENANT_ADMIN_GUIDE.md deleted file mode 100644 index 39eeafc..0000000 --- a/myproject/TENANT_ADMIN_GUIDE.md +++ /dev/null @@ -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. diff --git a/myproject/TENANT_REGISTRATION_GUIDE.md b/myproject/TENANT_REGISTRATION_GUIDE.md deleted file mode 100644 index b13fc4f..0000000 --- a/myproject/TENANT_REGISTRATION_GUIDE.md +++ /dev/null @@ -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` установлен - ---- - -**Система готова к использованию!** - -Теперь вы можете: -- Принимать заявки на регистрацию -- Модерировать их через админку -- Управлять подписками -- Контролировать доступ к магазинам diff --git a/myproject/TESTS_README.md b/myproject/TESTS_README.md deleted file mode 100644 index e3155b5..0000000 --- a/myproject/TESTS_README.md +++ /dev/null @@ -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 секунды diff --git a/myproject/inventory/tests/README.md b/myproject/inventory/tests/README.md deleted file mode 100644 index 062b2fa..0000000 --- a/myproject/inventory/tests/README.md +++ /dev/null @@ -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 diff --git a/myproject/products/tests/README_TESTS.md b/myproject/products/tests/README_TESTS.md deleted file mode 100644 index ccf87ad..0000000 --- a/myproject/products/tests/README_TESTS.md +++ /dev/null @@ -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 проекта. diff --git a/myproject/products/views/README.md b/myproject/products/views/README.md deleted file mode 100644 index e46ffb0..0000000 --- a/myproject/products/views/README.md +++ /dev/null @@ -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. Создать отдельный модуль для миксинов и базовых классов diff --git a/nested-singing-rainbow.md b/nested-singing-rainbow.md deleted file mode 100644 index e22d11a..0000000 --- a/nested-singing-rainbow.md +++ /dev/null @@ -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 %} -
-
Баланс клиента
-
    -
  • Общий баланс: {{ order.customer.account_balance }} руб.
  • -
  • Доступно: {{ order.customer.available_balance }} руб.
  • -
  • Зарезервировано: {{ order.customer.reserved_balance }} руб.
  • -
-
-{% 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-использованию после прохождения всех фаз тестирования. diff --git a/ЗАПУСК.md b/ЗАПУСК.md deleted file mode 100644 index 425acdb..0000000 --- a/ЗАПУСК.md +++ /dev/null @@ -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? -Да, но тогда блокировки не будут автоматически сниматься при истечении таймаута. -Кассиры все равно смогут работать, просто букет останется "занят" до ручного удаления из корзины. diff --git a/УДАЛЕНИЕ_ТЕНАНТОВ.md b/УДАЛЕНИЕ_ТЕНАНТОВ.md deleted file mode 100644 index cb0468e..0000000 --- a/УДАЛЕНИЕ_ТЕНАНТОВ.md +++ /dev/null @@ -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 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 -- 📖 Добавлены примеры использования для разных сценариев