Compare commits

...

71 Commits

Author SHA1 Message Date
5700314b10 feat(ui): replace alert notifications with toast messages
Add toast notification functionality using Bootstrap Toasts and update
checkout success/error handling to use toast messages instead of alert boxes.

**Changes:**
- Add `showToast` function to `terminal.js`
- Add toast container and templates to `terminal.html`
- Replace alert() calls in handleCheckoutSubmit with showToast()
2026-01-26 17:44:03 +03:00
b24a0d9f21 feat: Add UI for inventory transfer list and detail views. 2026-01-25 16:44:54 +03:00
034be20a5a feat: add showcase manager service 2026-01-25 15:28:41 +03:00
f75e861bb8 feat: Add new inventory and POS components, including a script to reproduce a POS checkout sale price bug. 2026-01-25 15:26:57 +03:00
5a66d492c8 feat: Add product kit views. 2026-01-25 00:52:03 +03:00
6cd0a945de feat: Add product kit creation view and its corresponding template. 2026-01-25 00:50:38 +03:00
41e6c33683 feat: Add Product Kit creation and editing functionality with new views and templates. 2026-01-25 00:09:45 +03:00
bf399996b8 fix(products): remove obsolete delete methods from ProductKit
Remove custom delete() and hard_delete() methods that referenced
non-existent is_deleted/deleted_at fields. ProductKit now uses
the correct implementation from BaseProductEntity which uses
status='discontinued' for soft delete.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:52:32 +03:00
2bc70968c3 fix(pos): restrict quantity editing for showcase kits in cart edit modal
For showcase kits (showcase_kit type), the quantity field is now disabled
in the cart item edit modal since these are pre-assembled physical items
with reservations. Price editing remains available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:49:02 +03:00
38fbf36731 feat(pos): add write-off functionality for showcase kits
Add support for writing off showcase kits by creating a write-off document with components, converting reservations, and updating statuses.

- Add `write_off_from_showcase` static method to ShowcaseManager
- Add API endpoint `/pos/api/product-kits/<int:kit_id>/write-off/`
- Add write-off button to POS terminal UI
- Implement confirmation dialog with detailed information
- Add redirect to write-off document detail page after success

The write-off process includes:
1. Creating a write-off document in draft state
2. Converting existing reservations to write-off document items
3. Marking the showcase item as dismantled
4. Setting the product kit status to discontinued (if not already)

Breaking Changes: No
2026-01-24 03:21:56 +03:00
9c91a99189 refactor(pos): simplify showcase kit default name
Replace datetime-based naming with simple "Витринный букет XXX" format,
where XXX is a random 3-digit number (100-999). Date is now handled by
the separate showcase_created_at field.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:54:58 +03:00
1eec8b1cd5 chore(pos): remove debug logs from showcase date feature
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:44:56 +03:00
977ee91fee feat(pos): add editable showcase creation date for kits
- Add showcase_created_at field to ProductKit model
- Display days ago as badge in product card (0 дней, 1 день, etc.)
- Add date input field in edit modal
- Auto-set current date/time for new showcase kits

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:37:27 +03:00
fce8d9eb6e fix(products): correct kit-update URL to productkit-update in category list
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:00:46 +03:00
5070913346 fix(products): correct URL name for kit detail in category list template
Changed 'products:kit-detail' to 'products:productkit-detail' in category_list.html
to fix NoReverseMatch error when rendering category tree with kits.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 23:53:49 +03:00
87f6484258 fix(products): update kit price calculation to use actual_price instead of get_sale_price() 2026-01-23 23:51:09 +03:00
14c1a4f804 chore(config): enable debug mode in Django settings 2026-01-23 23:48:23 +03:00
adbbd7539b fix(orders): ensure modified_by field is set correctly for CustomUser instances
Add type check for request.user being CustomUser instance before setting order.modified_by field
in order_create and order_update views to prevent errors when user is not a CustomUser (e.g., admin user)
2026-01-23 23:28:49 +03:00
5ec5ee48d4 feat(integrations): add dynamic OpenRouter model loading
- Remove hardcoded OPENROUTER_MODEL_CHOICES from openrouter.py
- Add API endpoint /integrations/openrouter/models/ to fetch models dynamically
- Models loaded from OpenRouter API with free models (':free') at top
- Update OpenRouterIntegration model_name field (remove choices, blank=True)
- Add async buildForm() with dynamic_choices support
- Show asterisks (********) for saved API keys with helpful placeholder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:16:12 +03:00
3aac83474b refactor(ai): improve bouquet name balancing and normalization
- Filter names by word count (2, 3, 4 words) for balanced distribution
- Remove duplicates per word count category
- Merge names in 2:3:4 proportion to ensure equal representation
- Update normalization to lowercase all words except first letter of first word
- Replace simple deduplication with structured balancing logic
2026-01-23 17:44:02 +03:00
4a624d5fef feat(ai): улучшить требования к генерации названий букетов
- Изменить требование к количеству слов на равную пропорцию 2, 3 или 4 слов
- Добавить новые требования к качеству названий
- Добавить примеры хороших названий для лучшего понимания
- Улучшить структуру и читаемость запроса к AI-сервису
2026-01-23 15:25:25 +03:00
9ddf54f398 refactor(ai): улучшить архитектуру генератора названий букетов
- Добавить константы для параметров генерации
- Улучшить валидацию входных параметров
- Оптимизировать выбор AI-сервиса
- Реализовать нормализацию регистра названий
- Добавить обработку ошибок при сохранении в базу данных
- Улучшить логику фильтрации нежелательных префиксов
- Рефакторить метод generate_and_store для лучшей читаемости
2026-01-23 15:18:51 +03:00
84cfc5cd47 Улучшение генератора названий для букетов
- Добавлена функциональность для кнопок 'ВЗЯТЬ' и 'УДАЛИТЬ'
- Реализовано получение и удаление названий из базы данных
- Исправлена фильтрация названий
- Исправлена проблема с обработчиками событий
2026-01-23 14:10:00 +03:00
59f7a7c520 feat: add OpenRouter AI service integration 2026-01-22 22:11:39 +03:00
22e300394b Исправление ошибки POS: разрешено добавление в корзину для PlatformAdmin (использование session_id вместо пользователя). Включены изменения по AI названиям букетов. 2026-01-22 20:29:05 +03:00
01873be15d feat(products): добавить генератор названий букетов с ai и тесты
- Добавить модуль ai с генератором названий букетов
- Обновить __init__.py для экспорта нового сервиса
- Добавить тесты для проверки работы генератора
2026-01-22 12:12:57 +03:00
036b9d1634 feat(products): добавить загрузку изображений по URL для комплектов
Добавлена возможность загружать фотографии комплекта по прямой ссылке
на формах создания и редактирования. JavaScript скачивает изображение
и добавляет его как файл в форму для отправки на сервер.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:24:42 +03:00
391d48640b fix(products): исправить калькулятор цены и выбор единицы продажи при создании комплекта
- Добавить объявление salesUnitSelect в calculateFinalPrice
- Добавить обновление Select2 после загрузки единиц продажи
- Добавить вызов updateSalesUnitsOptions при выборе товара
- Получать цену единицы продажи из DOM option элемента
- Исправить опечатку в заголовке списка товаров

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:45:36 +03:00
07a9de040f feat(orders): добавить фильтр для показа завершённых заказов
Добавлен новый булевый фильтр "show_all_orders" в OrderFilter, позволяющий отображать все заказы, включая завершённые и отменённые. Обновлён шаблон order_list.html с добавлением тумблера для автоматической отправки формы при переключении фильтра. Фильтр по умолчанию показывает только активные заказы.
2026-01-21 21:16:58 +03:00
622c544182 feat(inventory): добавить столбец текущей цены продажи и inline-редактирование количества
- Добавлен столбец "Текущая цена продажи" в таблицу позиций документа поступления
- Цена продажи отображается с учётом скидок и возможностью inline-редактирования для владельца и менеджера
- Реализовано inline-редактирование количества при клике на значение
- Добавлены стили для интерактивных элементов

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:54:35 +03:00
ffc5f4cfc1 feat(inventory): учитывать коэффициент конверсии при резервировании компонентов комплектов
Добавлены поля original_sales_unit и conversion_factor в KitItemSnapshot для хранения
единиц продажи и коэффициентов конверсии на момент создания снимка. Обновлена логика
резервирования запасов для корректного расчета количества в базовых единицах.

Изменения в шаблоне редактирования комплектов для сохранения выбранных единиц продажи
при обновлении списка опций.

BREAKING CHANGE: Изменена структура данных в KitItemSnapshot, требуется миграция базы данных.
2026-01-21 11:05:00 +03:00
e138a28475 Исправление ошибок в редактировании комплектов: валидация, верстка, расчет цены 2026-01-21 10:16:37 +03:00
2dc36b3d01 fix(inventory): создавать Sale после применения скидок в POS checkout
Добавлен механизм skip_sale_creation на базе thread-local storage
для управления моментом создания Sale через сигнал.

Проблема: сигнал create_sale_on_order_completion срабатывал при
Order.objects.create(status=completed) до применения скидок.

Решение: пропускать сигнал во время создания заказа, затем явно
создавать Sale после применения всех скидок через order.save().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 01:05:22 +03:00
1e4b7598ae refactor(tests): удалить проверку истории переходов в test_order_status_transitions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 00:38:42 +03:00
2620eea779 feat(products): сделать base_unit nullable для товаров
Разрешить создание товаров без базовой единицы измерения.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 00:37:11 +03:00
1071f3cacc fix(inventory): учитывать скидки при расчёте цены продажи с единицами измерения
- Пересчитывать цену в базовые единицы: price * conversion_factor
- Вычислять скидку как разницу между subtotal и total_amount
- Распределять скидку пропорционально долям позиций
- Использовать refresh_from_db() для актуального total_amount

Пример: 20 ед. (коэфф. 5) по 7₽ со скидкой 10% → Sale: 4 шт. по 31.5₽

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 00:35:43 +03:00
6b327fa7e0 fix(inventory): учитывать скидки на позицию и заказ при расчёте цены продажи
Расширяем логику расчёта цены продажи для учёта как скидок на отдельные позиции,
так и скидок на весь заказ. Скидка на заказ распределяется пропорционально доле
каждой позиции в общей сумме заказа.

Изменения внесены в SaleProcessor и сигнал создания продажи при завершении заказа.
2026-01-20 23:40:27 +03:00
0938878e67 fix(inventory): учитывать скидку при расчёте цены продажи
Внесены изменения в SaleProcessor и сигнал создания продажи для корректного
расчёта цены с учётом скидки на товар. Теперь при наличии discount_amount
производится пересчёт цены за единицу товара с учётом скидки перед
конвертацией в базовые единицы измерения.

Это исправляет ошибку, при которой скидка не учитывалась в итоговой цене продажи.
2026-01-20 23:20:55 +03:00
9cd3796527 feat(woocommerce): реализовать проверку соединения с WooCommerce API
- Добавлена реализация метода test_connection() с обработкой различных HTTP статусов
- Реализованы вспомогательные методы _get_api_url() и _get_auth() для работы с API
- Добавлена интеграция WooCommerceService в get_integration_service()
- Настроены поля формы для WooCommerceIntegration в get_form_fields_meta()

fix(inventory): исправить расчет цены продажи в базовых единицах

- Исправлен расчет sale_price в SaleProcessor с учетом conversion_factor_snapshot
- Обновлен расчет цены в сигнале create_sale_on_order_completion для корректной работы с sales_unit
2026-01-20 23:05:18 +03:00
271ac66098 fix(forms): использовать format_decimal для input type=number
Заменён floatformat на format_decimal в полях input type="number",
чтобы числа всегда использовали точку как десятичный разделитель,
независимо от локали. Это исправляет ошибки парсинга в браузере.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 01:20:50 +03:00
0b5db0c2e6 fix(discounts): исправить предзаполнение полей формы скидки при редактировании
- Обновлена логика отображения значений полей в шаблоне discount_form.html
- Теперь корректно обрабатываются значения как для новых, так и для существующих записей
- Устранены проблемы с отображением значений по умолчанию и выбранных опций
- Добавлена проверка на наличие instance.pk для определения режима редактирования
2026-01-20 00:54:38 +03:00
4b384ef359 fix(kits): не допускать отрицательного количества комплектов
Добавить проверку min_available <= 0 и возвращать 0 в таких случаях,
вместо того чтобы возвращать отрицательное значение после int().
2026-01-19 23:35:09 +03:00
d76fd2e7b2 fix(discounts): исправить предзаполнение полей при редактировании
Заменить условный рендеринг {% if %} на фильтр |default:'' для
числовых полей, чтобы значения корректно отображались при
редактировании существующей скидки.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 23:31:43 +03:00
0b35b80ee7 refactor(ui): заменить стандартные формы на кастомные html элементы
- Заменены стандартные формы Django на кастомные HTML элементы для полей:
  название, описание, категории, теги и цена со скидкой
- Добавлена валидация и стилизация для каждого поля
- Улучшена структура и читаемость кода шаблона
2026-01-19 23:19:14 +03:00
229fb18440 fix(orders): разрешить создание заказа без адреса
Добавлена возможность выбора режима "Без адреса (заполнить позже)"
при создании заказа, что позволяет пользователям пропустить шаг
указания адреса доставки на этапе оформления
2026-01-19 23:04:08 +03:00
d87c602f5a fix(pos): отключать чекбокс своей цены при актуализации цен в витринном комплекте
При нажатии "Пересчитать по актуальным ценам" чекбокс "Установить свою цену (приоритет)"
оставался включенным, из-за чего финальная цена не обновлялась.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:24:41 +03:00
2778796118 feat(pos): фиксировать цены товаров в витринных комплектах
- Добавлено поле KitItem.unit_price для хранения зафиксированной цены
- Витринные комплекты больше не обновляются при изменении цен товаров
- Добавлен красный индикатор на карточке если цена неактуальна
- Добавлен warning в модалке редактирования с кнопкой "Актуализировать"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:59:44 +03:00
392471ff06 fix(pos): использовать изменённые цены товаров при создании витринного комплекта
При создании витринного комплекта из корзины теперь учитываются
изменённые цены товаров вместо оригинальных.
2026-01-19 12:13:05 +03:00
b188f5c2df feat(navbar): убрать иконки из пунктов меню
Убраны эмодзи и Bootstrap иконки из навигационной панели, оставлен только текст.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:06:06 +03:00
1b749ebe63 fix(pos): исправить загрузку витринных комплектов
- Добавить display_name в CustomUser (name или email)
- Исправить get_showcase_kits_api: заменить username на display_name
- Использовать Case/When с output_field для выбора имени на уровне БД

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 00:15:11 +03:00
017fa4b744 feat(pos): добавить редактирование цены товара в корзине
- Добавить модалку редактирования товара в корзине (edit_cart_item_modal.html)
- Создать JS модуль cart-item-editor.js для логики редактирования
- При клике на строку товара открывается модалка с возможностью изменения цены и количества
- Добавить визуальную индикацию изменённой цены (оранжевый цвет и звёздочка)
- Экспортировать корзину в window.cart для доступа из других модулей
- Добавить авто-выделение текста при фокусе в полях ввода

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 22:08:58 +03:00
961cfcb9cd При наведении на строку курсор меняется на pointer
Двойной клик открывает заказ на редактирование (как клик на карандаш)
2026-01-18 20:40:18 +03:00
b6206ebe09 fix(orders): убрать ограничение строк в резюме и добавить центровку
- Убрать -webkit-line-clamp для полного отображения резюме заказа
- Убрать клик для раскрытия/сворачивания текста
- Добавить vertical-align: middle для центровки содержимого ячеек

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 20:21:43 +03:00
e3949d249f feat(units): добавить единицу измерения "Коробка" в дефолтный набор
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 20:07:43 +03:00
e10f2c413b fix(units): добавить проверку прав PlatformAdmin и исправить запрос связи
- Добавить name="submit" к кнопке формы
- Запретить PlatformAdmin доступ к CRUD операций UnitOfMeasure
- Исправить запрос sales_units_using через ProductSalesUnit.objects.filter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:43:18 +03:00
1d4bbf6a6d fix(admin): исправить get_queryset в ProductAdmin
Заменить Product.all_objects.all() на super().get_queryset(request),
так как у модели Product нет менеджера all_objects (нет soft delete).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:23:00 +03:00
b31961f939 feat: add order form template HTML. 2026-01-18 18:45:34 +03:00
1400514fd3 fix: сохранение даты доставки в черновиках заказов
Исправлено чтение полей доставки из request.POST вместо form.cleaned_data,
так как они не включены в Meta.fields формы OrderForm.
Удалена отладочная информация.
2026-01-18 18:31:36 +03:00
0d882781da fix(orders): исправить удаление позиций заказа в формсете
- Исправлена логика удаления inline-форм для позиций заказа
- Добавлена обработка удаления сохранённых и новых форм
- Добавлено поле id и DELETE в OrderItemForm для корректной работы формсета
- Добавлена проверка на null для created_by на странице отладки
- Расширены права доступа к отладочной странице: теперь доступна owner и manager
- Добавлено логирование для отладки процесса обновления заказа
2026-01-18 17:16:34 +03:00
ab1e8ebd18 feat(mobile): скрыть виртуальную клавиатуру по enter
Добавляет обработчик события keydown для поля поиска. При нажатии
клавиши Enter вызывается метод blur(), чтобы скрыть виртуальную
клавиатуру на мобильных устройствах.
2026-01-18 15:35:44 +03:00
d182a7b16d feat(inventory): улучшить поиск товаров для инвентаризации
Добавлен параметр skip_stock_filter для отключения фильтрации по остаткам,
опция excludeKits для исключения комплектов из поиска, а также
добавлено явное указание API URL и расширенное логирование для отладки.
2026-01-18 11:12:17 +03:00
c4e7efc3b1 feat(mobile): добавить дропдаун "Ещё" в действия корзины
Добавлена кнопка выпадающего меню в мобильный интерфейс для доступа
к дополнительным функциям: "Отложенный заказ" и "На витрину".

Обновлен шаблон terminal.html с добавлением структуры дропдауна.
Добавлены стили в terminal.css для адаптивного отображения.
Реализована логика в terminal.js для обработки кликов по мобильным
кнопкам и вызова соответствующих десктопных действий.
2026-01-18 01:24:48 +03:00
3205f5a2ce test: verify sync after v7 update 2026-01-18 01:09:30 +03:00
5ca474a133 fix: Sync deploy script with NAS version (v7)
- Fix paths: docker-compose.yml (not docker compose.yml)
- Use docker-compose command (not docker compose)
- Now matches the working version on NAS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 01:08:55 +03:00
aac47afcb9 test: trigger deploy with docker-compose fix 2026-01-18 01:01:39 +03:00
b88ec3997e test: trigger deploy to verify fix 2026-01-18 00:50:05 +03:00
3006207812 fix: Use docker compose (v2) instead of docker-compose
- docker-compose is deprecated, use 'docker compose' instead
- This fixes the "docker-compose: not found" error in deploy script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 00:48:05 +03:00
c0401176a9 fix(ui): обновить текст заголовка 2026-01-18 00:45:50 +03:00
0060f746c8 fix: Move deploy log to mounted volume for visibility
- Change log path from /tmp to /Volume1/DockerAppsData/mixapp/
- /tmp inside webhook container != host /tmp
- Now logs are visible on host after deploy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 00:44:00 +03:00
2f8a78cfa7 fix(ui): обновить текст заголовка товаров 2026-01-18 00:33:24 +03:00
16194a1167 fix(ui): исправить текст заголовка списка товаров 2026-01-18 00:32:57 +03:00
79 changed files with 7062 additions and 1935 deletions

View File

@@ -75,6 +75,11 @@ class CustomUser(AbstractBaseUser):
def __str__(self):
return self.email
@property
def display_name(self):
"""Отображаемое имя пользователя: имя если есть, иначе email"""
return self.name or self.email
def has_perm(self, perm, obj=None):
"""
Проверка разрешения через authentication backends.

View File

@@ -1,4 +1,5 @@
{% extends "system_settings/base_settings.html" %}
{% load inventory_filters %}
{% block title %}{% if is_edit %}Редактирование скидки{% else %}Создание скидки{% endif %}{% endblock %}
@@ -33,13 +34,13 @@
<div class="col-md-6">
<label for="id_name" class="form-label">Название * <span class="text-muted small">(макс. 200 символов)</span></label>
<input type="text" class="form-control" id="id_name" name="name"
value="{% if form.name.value %}{{ form.name.value }}{% endif %}"
value="{{ form.name.value|default_if_none:'' }}"
maxlength="200" required>
</div>
<div class="col-md-6">
<label for="id_priority" class="form-label">Приоритет</label>
<input type="number" class="form-control" id="id_priority" name="priority"
value="{% if form.priority.value %}{{ form.priority.value }}{% else %}0{% endif %}"
value="{{ form.priority.value|default_if_none:0 }}"
min="0">
<div class="form-text">Выше = применяется раньше</div>
</div>
@@ -47,7 +48,7 @@
<div class="mb-3">
<label for="id_description" class="form-label">Описание</label>
<textarea class="form-control" id="id_description" name="description" rows="2">{{ form.description.value }}</textarea>
<textarea class="form-control" id="id_description" name="description" rows="2">{{ form.description.value|default_if_none:'' }}</textarea>
</div>
<!-- Параметры скидки -->
@@ -57,23 +58,28 @@
<label for="id_discount_type" class="form-label">Тип скидки *</label>
<select class="form-select" id="id_discount_type" name="discount_type" required>
<option value="">Выберите...</option>
<option value="percentage" {% if form.discount_type.value == 'percentage' %}selected{% endif %}>Процент</option>
<option value="fixed_amount" {% if form.discount_type.value == 'fixed_amount' %}selected{% endif %}>Фиксированная сумма (руб.)</option>
<option value="percentage"
{% if form.discount_type.value == 'percentage' %}selected{% endif %}>Процент</option>
<option value="fixed_amount"
{% if form.discount_type.value == 'fixed_amount' %}selected{% endif %}>Фиксированная сумма (руб.)</option>
</select>
</div>
<div class="col-md-4">
<label for="id_value" class="form-label">Значение *</label>
<input type="number" class="form-control" id="id_value" name="value"
value="{% if form.value.value %}{{ form.value.value }}{% endif %}"
value="{{ form.value.value|format_decimal:2|default_if_none:'' }}"
step="0.01" min="0" required>
</div>
<div class="col-md-4">
<label for="id_scope" class="form-label">Область действия *</label>
<select class="form-select" id="id_scope" name="scope" required>
<option value="">Выберите...</option>
<option value="order" {% if form.scope.value == 'order' %}selected{% endif %}>На весь заказ</option>
<option value="product" {% if form.scope.value == 'product' %}selected{% endif %}>На конкретные товары</option>
<option value="category" {% if form.scope.value == 'category' %}selected{% endif %}>На категории товаров</option>
<option value="order"
{% if form.scope.value == 'order' %}selected{% endif %}>На весь заказ</option>
<option value="product"
{% if form.scope.value == 'product' %}selected{% endif %}>На конкретные товары</option>
<option value="category"
{% if form.scope.value == 'category' %}selected{% endif %}>На категории товаров</option>
</select>
</div>
</div>
@@ -92,7 +98,7 @@
<div class="col-md-6">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="id_is_active" name="is_active"
{% if form.is_active.value is None or form.is_active.value %}checked{% endif %}>
{% if form.is_active.value %}checked{% endif %}>
<label class="form-check-label" for="id_is_active">
Активна
</label>
@@ -104,13 +110,16 @@
<div class="mb-3">
<label for="id_combine_mode" class="form-label">Режим объединения с другими скидками</label>
<select class="form-select" id="id_combine_mode" name="combine_mode">
<option value="max_only" {% if form.combine_mode.value == 'max_only' or not form.combine_mode.value %}selected{% endif %}>
<option value="max_only"
{% if form.combine_mode.value == 'max_only' or not form.combine_mode.value %}selected{% endif %}>
🏆 Только максимум (применяется лучшая скидка)
</option>
<option value="stack" {% if form.combine_mode.value == 'stack' %}selected{% endif %}>
<option value="stack"
{% if form.combine_mode.value == 'stack' %}selected{% endif %}>
📚 Складывать (суммировать с другими)
</option>
<option value="exclusive" {% if form.combine_mode.value == 'exclusive' %}selected{% endif %}>
<option value="exclusive"
{% if form.combine_mode.value == 'exclusive' %}selected{% endif %}>
🚫 Исключающая (отменяет остальные скидки)
</option>
</select>
@@ -123,14 +132,14 @@
<div class="col-md-6">
<label for="id_min_order_amount" class="form-label">Мин. сумма заказа</label>
<input type="number" class="form-control" id="id_min_order_amount" name="min_order_amount"
value="{% if form.min_order_amount.value %}{{ form.min_order_amount.value }}{% endif %}"
value="{{ form.min_order_amount.value|format_decimal:2|default_if_none:'' }}"
step="0.01" min="0">
<div class="form-text">Для скидок на заказ</div>
</div>
<div class="col-md-6">
<label for="id_max_usage_count" class="form-label">Макс. использований</label>
<input type="number" class="form-control" id="id_max_usage_count" name="max_usage_count"
value="{% if form.max_usage_count.value %}{{ form.max_usage_count.value }}{% endif %}"
value="{{ form.max_usage_count.value|default_if_none:'' }}"
min="1">
<div class="form-text">Оставьте пустым для безлимитного использования</div>
</div>
@@ -140,12 +149,12 @@
<div class="col-md-6">
<label for="id_start_date" class="form-label">Дата начала</label>
<input type="datetime-local" class="form-control" id="id_start_date" name="start_date"
value="{% if form.start_date.value %}{{ form.start_date.value|date:'Y-m-d\TH:i' }}{% endif %}">
value="{{ form.start_date.value|date:'Y-m-d\TH:i'|default_if_none:'' }}">
</div>
<div class="col-md-6">
<label for="id_end_date" class="form-label">Дата окончания</label>
<input type="datetime-local" class="form-control" id="id_end_date" name="end_date"
value="{% if form.end_date.value %}{{ form.end_date.value|date:'Y-m-d\TH:i' }}{% endif %}">
value="{{ form.end_date.value|date:'Y-m-d\TH:i'|default_if_none:'' }}">
</div>
</div>
@@ -174,7 +183,6 @@
{% if not all_categories %}
<option value="" disabled>Нет доступных категорий</option>
{% endif %}
%}
</select>
<div class="form-text">Удерживайте Ctrl для выбора нескольких категорий</div>
</div>
@@ -204,4 +212,46 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Заменяем запятые на точки в числовых полях при загрузке страницы
const numberInputs = document.querySelectorAll('input[type="number"]');
numberInputs.forEach(function(input) {
if (input.value && input.value.includes(',')) {
// Сохраняем оригинальное значение с запятой для отображения
const displayValue = input.value;
const actualValue = displayValue.replace(',', '.');
// Устанавливаем значение с точкой для корректной работы HTML5 поля
input.value = actualValue;
// При фокусе возвращаем запятую для удобства пользователя
input.addEventListener('focus', function() {
if (input.value && input.value.includes('.')) {
input.value = input.value.replace('.', ',');
}
});
// При потере фокуса возвращаем точку для корректной отправки
input.addEventListener('blur', function() {
if (input.value && input.value.includes(',')) {
input.value = input.value.replace(',', '.');
}
});
}
});
// Также обрабатываем отправку формы для замены запятых на точки
const form = document.querySelector('form');
form.addEventListener('submit', function() {
const numberInputs = document.querySelectorAll('input[type="number"]');
numberInputs.forEach(function(input) {
if (input.value && input.value.includes(',')) {
input.value = input.value.replace(',', '.');
}
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.10 on 2026-01-23 15:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0009_alter_glmintegration_model_name_and_more'),
]
operations = [
migrations.AlterField(
model_name='openrouterintegration',
name='model_name',
field=models.CharField(blank=True, default='', help_text='Название используемой модели OpenRouter (загружается автоматически)', max_length=200, verbose_name='Название модели'),
),
]

View File

@@ -10,14 +10,6 @@ def validate_temperature(value):
raise ValidationError('Температура должна быть в диапазоне 0.0-2.0')
# Список доступных моделей OpenRouter (бесплатные)
OPENROUTER_MODEL_CHOICES = [
('xiaomi/mimo-v2-flash:free', 'Xiaomi MIMO v2 Flash (Бесплатная)'),
('mistralai/devstral-2512:free', 'Mistral Devstral 2512 (Бесплатная)'),
('z-ai/glm-4.5-air:free', 'Z.AI GLM-4.5 Air (Бесплатная)'),
('qwen/qwen3-coder:free', 'Qwen 3 Coder (Бесплатная)'),
]
# Предустановленные значения температуры
OPENROUTER_TEMPERATURE_CHOICES = [
(0.1, '0.1 - Очень консервативно'),
@@ -59,11 +51,11 @@ class OpenRouterIntegration(AIIntegration):
)
model_name = models.CharField(
max_length=100,
default="xiaomi/mimo-v2-flash:free",
choices=OPENROUTER_MODEL_CHOICES,
max_length=200,
default="",
blank=True,
verbose_name="Название модели",
help_text="Название используемой модели OpenRouter"
help_text="Название используемой модели OpenRouter (загружается автоматически)"
)
temperature = models.FloatField(

View File

@@ -3,28 +3,45 @@ from ..base import BaseIntegrationService
from .config import get_openrouter_config
import logging
import sys
import locale
import traceback
# Патч для исправления проблемы с кодировкой в httpx на Windows
# Устанавливаем кодировку по умолчанию для Python
if sys.platform == 'win32':
try:
import httpx._models
original_normalize_header_value = httpx._models._normalize_header_value
# Сохраняем оригинальную функцию, если она есть
_original_normalize_header_value = getattr(httpx._models, '_normalize_header_value', None)
def patched_normalize_header_value(value, encoding):
"""Патч для использования UTF-8 вместо ASCII для заголовков"""
try:
# Если значение уже bytes, возвращаем его как есть
if isinstance(value, bytes):
return value
# Если значение не строка и не байты, приводим к строке
if not isinstance(value, str):
value = str(value)
# Всегда используем UTF-8 вместо ASCII
encoding = encoding or 'utf-8'
if encoding.lower() == 'ascii':
encoding = 'utf-8'
return value.encode(encoding)
except Exception as e:
# В случае ошибки логируем и пробуем максимально безопасный вариант
logging.getLogger(__name__).error(f"Error in patched_normalize_header_value: {e}. Value: {repr(value)}")
if isinstance(value, str):
return value.encode('utf-8', errors='ignore')
return b''
httpx._models._normalize_header_value = patched_normalize_header_value
logging.getLogger(__name__).info("Applied patch for httpx header encoding on Windows")
logging.getLogger(__name__).info("Applied robust patch for httpx header encoding on Windows")
except ImportError:
logging.getLogger(__name__).warning("httpx module not found, patch skipped")
except Exception as e:
logging.getLogger(__name__).warning(f"Failed to apply httpx patch: {e}")
@@ -148,8 +165,10 @@ class OpenRouterIntegrationService(BaseIntegrationService):
}
except Exception as e:
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {str(e)}")
return False, f"Ошибка генерации: {str(e)}", None
error_msg = str(e)
logger.error(f"Ошибка генерации текста с помощью OpenRouter: {error_msg}")
logger.error(traceback.format_exc())
return False, f"Ошибка генерации: {error_msg}", None
def generate_code(self,
prompt: str,
@@ -196,5 +215,7 @@ class OpenRouterIntegrationService(BaseIntegrationService):
}
except Exception as e:
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {str(e)}")
return False, f"Ошибка генерации кода: {str(e)}", None
error_msg = str(e)
logger.error(f"Ошибка генерации кода с помощью OpenRouter: {error_msg}")
logger.error(traceback.format_exc())
return False, f"Ошибка генерации кода: {error_msg}", None

View File

@@ -1,3 +1,4 @@
import requests
from typing import Tuple
from .base import MarketplaceService
@@ -5,16 +6,58 @@ from .base import MarketplaceService
class WooCommerceService(MarketplaceService):
"""Сервис для работы с WooCommerce API"""
def _get_api_url(self) -> str:
"""Получить базовый URL для WooCommerce REST API"""
base = self.config.store_url.rstrip('/')
# WooCommerce REST API v3 endpoint
return f"{base}/wp-json/wc/v3/"
def _get_auth(self) -> tuple:
"""Получить кортеж для Basic Auth (consumer_key, consumer_secret)"""
return (self.config.consumer_key or '', self.config.consumer_secret or '')
def test_connection(self) -> Tuple[bool, str]:
"""Проверить соединение с WooCommerce API"""
"""
Проверить соединение с WooCommerce API.
Использует endpoint /wp-json/wc/v3/ для проверки.
Аутентификация через HTTP Basic Auth.
"""
if not self.config.store_url:
return False, 'Не указан URL магазина'
if not self.config.consumer_key or not self.config.consumer_secret:
return False, 'Не указаны ключи API'
# TODO: реализовать проверку соединения с WooCommerce API
return True, 'Соединение успешно (заглушка)'
url = self._get_api_url()
try:
# Пытаемся получить список товаров (limit=1) для проверки авторизации
# Это более надёжный способ проверки, чем просто обращение к корню API
response = requests.get(
f"{url}products",
params={'per_page': 1},
auth=self._get_auth(),
timeout=15
)
if response.status_code == 200:
return True, 'Соединение установлено успешно'
elif response.status_code == 401:
return False, 'Неверные ключи API (Consumer Key/Secret)'
elif response.status_code == 403:
return False, 'Доступ запрещён. Проверьте права API ключа'
elif response.status_code == 404:
return False, 'WooCommerce REST API не найден. Проверьте, что WooCommerce установлен и активирован'
else:
return False, f'Ошибка соединения: HTTP {response.status_code}'
except requests.exceptions.Timeout:
return False, 'Таймаут соединения (15 сек)'
except requests.exceptions.ConnectionError:
return False, 'Не удалось подключиться к серверу. Проверьте URL магазина'
except Exception as e:
return False, f'Ошибка: {str(e)}'
def sync(self) -> Tuple[bool, str]:
"""Выполнить синхронизацию с WooCommerce"""

View File

@@ -6,6 +6,7 @@ from .views import (
get_integration_form_data,
test_integration_connection,
RecommerceBatchSyncView,
get_openrouter_models,
)
app_name = 'integrations'
@@ -22,4 +23,7 @@ urlpatterns = [
# Синхронизация
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
# OpenRouter модели
path("openrouter/models/", get_openrouter_models, name="openrouter_models"),
]

View File

@@ -1,7 +1,10 @@
import json
import logging
from django.views.generic import TemplateView
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_POST, require_GET
logger = logging.getLogger(__name__)
from user_roles.mixins import OwnerRequiredMixin
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
@@ -170,8 +173,8 @@ def get_integration_service(integration_id: str, instance):
from .services.marketplaces.recommerce import RecommerceService
return RecommerceService(instance)
elif integration_id == 'woocommerce':
# TODO: WooCommerceService
return None
from .services.marketplaces.woocommerce import WooCommerceService
return WooCommerceService(instance)
elif integration_id == 'glm':
from .services.ai_services.glm_service import GLMIntegrationService
return GLMIntegrationService(instance)
@@ -181,6 +184,44 @@ def get_integration_service(integration_id: str, instance):
return None
@require_GET
def get_openrouter_models(request):
"""
GET /settings/integrations/openrouter/models/
Возвращает список моделей OpenRouter (бесплатные сверху)
"""
import requests
try:
response = requests.get('https://openrouter.ai/api/v1/models', timeout=10)
response.raise_for_status()
data = response.json()
models = data.get('data', [])
# Разделить на бесплатные и платные
free_models = []
paid_models = []
for model in models:
model_id = model.get('id', '')
model_name = model.get('name', model_id)
if ':free' in model_id:
free_models.append({'id': model_id, 'name': f"{model_name} (Бесплатная)"})
else:
paid_models.append({'id': model_id, 'name': model_name})
# Бесплатные сверху
all_models = free_models + paid_models
return JsonResponse({'models': all_models})
except Exception as e:
logger.error(f"Error fetching OpenRouter models: {e}")
return JsonResponse({'error': str(e)}, status=500)
class RecommerceBatchSyncView(TemplateView):
"""
API View для запуска массовой синхронизации с Recommerce.
@@ -363,7 +404,46 @@ def get_form_fields_meta(model):
'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True),
'type': 'text', # default
'type': 'password' if field_name == 'api_key' else 'text',
}
fields.append(field_info)
elif field_name == 'temperature':
field = model._meta.get_field(field_name)
field_info = {
'name': field_name,
'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True),
'type': 'select',
'choices': getattr(field, 'choices', [])
}
fields.append(field_info)
elif field_name == 'model_name':
field = model._meta.get_field(field_name)
field_info = {
'name': field_name,
'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True),
'type': 'select',
'dynamic_choices': True,
'choices_url': '/settings/integrations/openrouter/models/'
}
fields.append(field_info)
# Для WooCommerce показываем только базовые поля для подключения
elif model.__name__ == 'WooCommerceIntegration':
basic_fields = ['store_url', 'consumer_key', 'consumer_secret']
for field_name in editable_fields:
if field_name in basic_fields:
field = model._meta.get_field(field_name)
field_info = {
'name': field_name,
'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True),
'type': 'text',
}
# Определить тип поля
@@ -371,21 +451,9 @@ def get_form_fields_meta(model):
field_info['type'] = 'checkbox'
elif 'URLField' in field.__class__.__name__:
field_info['type'] = 'url'
elif 'secret' in field_name.lower() or 'token' in field_name.lower() or 'key' in field_name.lower():
elif 'secret' in field_name.lower() or 'key' in field_name.lower():
field_info['type'] = 'password'
fields.append(field_info)
elif field_name in ['model_name', 'temperature']:
field = model._meta.get_field(field_name)
field_info = {
'name': field_name,
'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True),
'type': 'select', # dropdown
'choices': getattr(field, 'choices', [])
}
fields.append(field_info)
else:
# Для других интеграций - все редактируемые поля

View File

@@ -667,7 +667,16 @@ class ShowcaseItem(models.Model):
from datetime import timedelta
self.status = 'in_cart'
# Проверяем тип пользователя - locked_by_user только для CustomUser
from accounts.models import CustomUser
if isinstance(user, CustomUser):
self.locked_by_user = user
else:
# Для PlatformAdmin и других типов пользователей поле оставляем пустым
# Блокировка будет работать через cart_session_id
self.locked_by_user = None
self.cart_lock_expires_at = timezone.now() + timedelta(minutes=duration_minutes)
self.cart_session_id = session_id
self.save(update_fields=['status', 'locked_by_user', 'cart_lock_expires_at', 'cart_session_id', 'updated_at'])

View File

@@ -35,8 +35,34 @@ class SaleProcessor:
"""
# Определяем цену продажи из заказа или из товара
if order and reservation.order_item:
# Цена из OrderItem
sale_price = reservation.order_item.price
item = reservation.order_item
# Цена за единицу с учётом всех скидок (позиция + заказ)
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
# Скидка на позицию
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount else Decimal('0')
# Скидка на заказ (распределяется пропорционально доле позиции в заказе)
# Вычисляем как разницу между subtotal и total_amount (так как discount_amount может быть 0)
order_total = order.subtotal if hasattr(order, 'subtotal') else Decimal('0')
# Скидка = subtotal - (total_amount - delivery) (вычитаем доставку, если есть)
delivery_cost = Decimal(str(order.delivery.cost)) if hasattr(order, 'delivery') and order.delivery else Decimal('0')
order_discount = (order_total - (Decimal(str(order.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
total_discount = item_discount + order_discount
if total_discount and item.quantity > 0:
# Распределяем общую скидку пропорционально доле позиции
item_order_discount = order_discount * (item_subtotal / order_total) if order_total > 0 else Decimal('0')
total_discount = item_discount + item_order_discount
price_with_discount = (item_subtotal - total_discount) / Decimal(str(item.quantity))
else:
price_with_discount = Decimal(str(item.price))
# Пересчитываем цену в базовые единицы
if item.sales_unit and item.conversion_factor_snapshot:
sale_price = price_with_discount * item.conversion_factor_snapshot
else:
sale_price = price_with_discount
else:
# Цена из товара
sale_price = reservation.product.actual_price or Decimal('0')

View File

@@ -162,8 +162,6 @@ class ShowcaseManager:
Raises:
IntegrityError: если экземпляр уже был продан (защита на уровне БД)
"""
from inventory.services.sale_processor import SaleProcessor
sold_count = 0
order = order_item.order
@@ -207,17 +205,9 @@ class ShowcaseManager:
# Сначала устанавливаем order_item для правильного определения цены
reservation.order_item = order_item
reservation.save()
# Теперь создаём продажу с правильной ценой из OrderItem
SaleProcessor.create_sale_from_reservation(
reservation=reservation,
order=order
)
# Обновляем статус резерва
reservation.status = 'converted_to_sale'
reservation.converted_at = timezone.now()
# ВАЖНО: Мы НЕ создаём продажу (Sale) здесь и НЕ меняем статус на 'converted_to_sale'.
# Это сделает сигнал create_sale_on_order_completion автоматически.
# Таким образом обеспечивается единая точка создания продаж для всех типов товаров.
reservation.save()
sold_count += 1
@@ -666,6 +656,113 @@ class ShowcaseManager:
'message': f'Ошибка разбора: {str(e)}'
}
@staticmethod
def write_off_from_showcase(showcase_item, reason='spoilage', notes=None, created_by=None):
"""
Списывает экземпляр витринного комплекта:
1. Создаёт документ списания с компонентами комплекта
2. Преобразует резервы комплекта в позиции документа списания
3. Помечает экземпляр как разобранный
Args:
showcase_item: ShowcaseItem - экземпляр для списания
reason: str - причина списания (spoilage по умолчанию)
notes: str - примечания
created_by: User - пользователь
Returns:
dict: {
'success': bool,
'document_id': int,
'document_number': str,
'items_count': int,
'message': str,
'error': str (при ошибке)
}
"""
from inventory.services.writeoff_document_service import WriteOffDocumentService
# Проверка статуса
if showcase_item.status == 'sold':
return {
'success': False,
'document_id': None,
'message': 'Нельзя списать проданный экземпляр'
}
if showcase_item.status == 'dismantled':
return {
'success': False,
'document_id': None,
'message': 'Экземпляр уже разобран'
}
try:
with transaction.atomic():
warehouse = showcase_item.showcase.warehouse
product_kit = showcase_item.product_kit
# Создаём документ списания (черновик)
document = WriteOffDocumentService.create_document(
warehouse=warehouse,
date=timezone.now().date(),
notes=f'Списание витринного комплекта: {product_kit.name}',
created_by=created_by
)
# Получаем резервы этого экземпляра
reservations = Reservation.objects.filter(
showcase_item=showcase_item,
status='reserved'
).select_related('product')
items_count = 0
for reservation in reservations:
# Добавляем позицию в документ списания
# Используем add_item без создания резерва (меняем статус существующего)
from inventory.models import WriteOffDocumentItem
item = WriteOffDocumentItem.objects.create(
document=document,
product=reservation.product,
quantity=reservation.quantity,
reason=reason,
notes=notes
)
# Привязываем существующий резерв к позиции документа
reservation.writeoff_document_item = item
reservation.status = 'converted_to_writeoff'
reservation.converted_at = timezone.now()
reservation.save(update_fields=['writeoff_document_item', 'status', 'converted_at'])
items_count += 1
# Помечаем экземпляр как разобранный
showcase_item.status = 'dismantled'
showcase_item.save(update_fields=['status'])
# Помечаем шаблон комплекта как снятый
if product_kit.status != 'discontinued':
product_kit.status = 'discontinued'
product_kit.save(update_fields=['status'])
return {
'success': True,
'document_id': document.id,
'document_number': document.document_number,
'items_count': items_count,
'message': f'Создан документ {document.document_number} с {items_count} позициями'
}
except Exception as e:
return {
'success': False,
'document_id': None,
'message': f'Ошибка списания: {str(e)}'
}
@staticmethod
def get_showcase_items_for_pos(showcase=None):
"""

View File

@@ -4,6 +4,7 @@
Подключаются при создании, изменении и удалении заказов.
"""
import threading
from django.db.models.signals import post_save, pre_delete, post_delete, pre_save
from django.db.models import Q
from django.db import transaction
@@ -19,6 +20,26 @@ from inventory.services import SaleProcessor
from inventory.services.batch_manager import StockBatchManager
# InventoryProcessor больше не используется в сигналах - обработка вызывается явно через view
# ============================================================================
# Thread-local storage для временных флагов управления сигналами
# ============================================================================
_skip_sale_creation = threading.local()
def skip_sale_creation():
"""Установить флаг для пропуска создания Sale в сигнале."""
_skip_sale_creation.value = True
def reset_sale_creation():
"""Сбросить флаг пропуска создания Sale."""
_skip_sale_creation.value = False
def is_skip_sale_creation():
"""Проверить, установлен ли флаг пропуска создания Sale."""
return getattr(_skip_sale_creation, 'value', False)
# ============================================================================
# pre_save сигнал для сохранения предыдущего статуса Order
@@ -201,9 +222,14 @@ def reserve_stock_on_item_create(sender, instance, created, **kwargs):
for kit_item in instance.kit_snapshot.items.select_related('original_product'):
if kit_item.original_product:
# Суммируем количество: qty компонента * qty комплектов в заказе
# Рассчитываем количество одного компонента в базовых единицах
component_qty_base = kit_item.quantity
if kit_item.conversion_factor and kit_item.conversion_factor > 0:
component_qty_base = kit_item.quantity / kit_item.conversion_factor
# Суммируем количество: qty компонента (base) * qty комплектов в заказе
product_quantities[kit_item.original_product_id] += (
kit_item.quantity * Decimal(str(instance.quantity))
component_qty_base * Decimal(str(instance.quantity))
)
# Создаём по одному резерву на каждый уникальный товар
@@ -278,10 +304,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
3. Для каждого товара создаем Sale (автоматический FIFO-список)
4. ТОЛЬКО после успешного создания Sale обновляем резервы на 'converted_to_sale'
5. Обновляем флаг is_returned
ПРИМЕЧАНИЕ: Если у Order установлен атрибут skip_sale_creation=True,
создание Sale пропускается (используется в POS для создания Sale после применения скидок).
"""
import logging
logger = logging.getLogger(__name__)
# === ПРОВЕРКА: Пропуск создания Sale по флагу ===
# Используется в POS checkout, где Sale создаётся явно после применения скидок
if is_skip_sale_creation():
logger.info(
f" Заказ {instance.order_number}: skip_sale_creation=True (thread-local), "
f"пропускаем автоматическое создание Sale"
)
return
if created:
return # Только для обновлений
@@ -325,16 +363,22 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
if not is_positive_end:
return # Только для положительных финальных статусов (completed и т.п.)
# === ЗАЩИТА ОТ ПРЕЖДЕВРЕМЕННОГО СОЗДАНИЯ SALE ===
# Проверяем, есть ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists():
logger.info(f"Заказ {instance.order_number}: Sale уже существуют, пропускаем")
update_is_returned_flag(instance)
return
# === ЗАЩИТА ОТ RACE CONDITION: Проверяем предыдущий статус ===
# Если уже были в completed и снова переходим в completed (например completed → draft → completed),
# проверяем наличие Sale чтобы избежать дублирования
previous_status = getattr(instance, '_previous_status', None)
if previous_status and previous_status.is_positive_end:
logger.info(
f"🔄 Заказ {instance.order_number}: повторный переход в положительный статус "
f"Заказ {instance.order_number}: повторный переход в положительный статус "
f"({previous_status.name}{instance.status.name}). Проверяем Sale..."
)
# Проверяем есть ли уже Sale
if Sale.objects.filter(order=instance).exists():
logger.info(
f"✓ Заказ {instance.order_number}: Sale уже существуют, пропускаем создание"
@@ -342,15 +386,6 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
update_is_returned_flag(instance)
return
# Защита от повторного списания: проверяем, не созданы ли уже Sale для этого заказа
if Sale.objects.filter(order=instance).exists():
# Продажи уже созданы — просто обновляем флаг is_returned и выходим
logger.info(
f"✓ Заказ {instance.order_number}: Sale уже существуют (проверка до создания)"
)
update_is_returned_flag(instance)
return
# Проверяем наличие резервов для этого заказа
# Ищем резервы в статусах 'reserved' (новые) и 'released' (после отката)
# Исключаем уже обработанные 'converted_to_sale'
@@ -419,12 +454,65 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
)
continue
# === РАСЧЕТ ЦЕНЫ ===
# Рассчитываем фактическую стоимость продажи всего комплекта с учетом скидок
# 1. Базовая стоимость позиции
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
# 2. Скидки
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
# Скидка на заказ (распределенная)
instance.refresh_from_db()
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0')
order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
if order_total > 0 and order_discount_amount > 0:
item_order_discount = order_discount_amount * (item_subtotal / order_total)
else:
item_order_discount = Decimal('0')
kit_net_total = item_subtotal - item_discount - item_order_discount
if kit_net_total < 0:
kit_net_total = Decimal('0')
# 3. Суммарная каталожная стоимость всех компонентов (для пропорции)
total_catalog_price = Decimal('0')
for reservation in kit_reservations:
qty = reservation.quantity_base or reservation.quantity
price = reservation.product.actual_price or Decimal('0')
total_catalog_price += price * qty
# 4. Коэффициент распределения
if total_catalog_price > 0:
ratio = kit_net_total / total_catalog_price
else:
# Если каталожная цена 0, распределяем просто по количеству или 0
ratio = Decimal('0')
# Создаем Sale для каждого компонента комплекта
for reservation in kit_reservations:
try:
# Рассчитываем цену продажи компонента пропорционально цене комплекта
# Используем actual_price компонента как цену продажи
component_sale_price = reservation.product.actual_price
# Рассчитываем цену продажи компонента пропорционально
catalog_price = reservation.product.actual_price or Decimal('0')
if ratio > 0:
# Распределяем реальную выручку
component_sale_price = catalog_price * ratio
else:
# Если выручка 0 или каталожные цены 0
if total_catalog_price == 0 and kit_net_total > 0:
# Крайний случай: товаров на 0 руб, а продали за деньги (услуга?)
# Распределяем равномерно
count = kit_reservations.count()
component_qty = reservation.quantity_base or reservation.quantity
if count > 0 and component_qty > 0:
component_sale_price = (kit_net_total / count) / component_qty
else:
component_sale_price = Decimal('0')
else:
component_sale_price = Decimal('0')
sale = SaleProcessor.create_sale(
product=reservation.product,
@@ -437,7 +525,8 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
sales_created.append(sale)
logger.info(
f"✓ Sale создан для компонента комплекта '{kit.name}': "
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. (базовых единиц)"
f"{reservation.product.name} - {reservation.quantity_base or reservation.quantity} шт. "
f"(цена: {component_sale_price})"
)
except ValueError as e:
logger.error(
@@ -480,12 +569,60 @@ def create_sale_on_order_completion(sender, instance, created, **kwargs):
f"Используем quantity_in_base_units: {sale_quantity}"
)
# Цена за единицу с учётом всех скидок (позиция + заказ)
item_subtotal = Decimal(str(item.price)) * Decimal(str(item.quantity))
# Скидка на позицию
item_discount = Decimal(str(item.discount_amount)) if item.discount_amount is not None else Decimal('0')
# Скидка на заказ (распределяется пропорционально доле позиции в заказе)
# ВАЖНО: Обновляем Order из БД, чтобы получить актуальный total_amount после применения скидок
instance.refresh_from_db()
order_total = instance.subtotal if hasattr(instance, 'subtotal') else Decimal('0')
# Скидка = subtotal - (total_amount - delivery) (вычитаем доставку, если есть)
delivery_cost = Decimal(str(instance.delivery.cost)) if hasattr(instance, 'delivery') and instance.delivery else Decimal('0')
order_discount_amount = (order_total - (Decimal(str(instance.total_amount)) - delivery_cost)) if order_total > 0 else Decimal('0')
if order_total > 0 and order_discount_amount > 0:
# Пропорциональная часть скидки заказа для этой позиции
item_order_discount = order_discount_amount * (item_subtotal / order_total)
else:
item_order_discount = Decimal('0')
total_discount = item_discount + item_order_discount
if total_discount and item.quantity > 0:
price_with_discount = (item_subtotal - total_discount) / Decimal(str(item.quantity))
else:
price_with_discount = Decimal(str(item.price))
# Пересчитываем цену в базовые единицы
if item.sales_unit and item.conversion_factor_snapshot:
base_price = price_with_discount * item.conversion_factor_snapshot
else:
base_price = price_with_discount
# LOGGING DEBUG INFO
# print(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
# print(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
# print(f" Item Discount: {item_discount}, Order Discount Share: {item_order_discount}, Total Discount: {total_discount}")
# print(f" Price w/ Discount: {price_with_discount}")
# print(f" Sales Unit: {item.sales_unit}, Conversion: {item.conversion_factor_snapshot}")
# print(f" FINAL BASE PRICE: {base_price}")
# print(f" Sales Unit Object: {item.sales_unit}")
# if item.sales_unit:
# print(f" Sales Unit Conversion: {item.sales_unit.conversion_factor}")
logger.info(f"DEBUG SALE PRICE CALCULATION for Item #{item.id} ({product.name}):")
logger.info(f" Price: {item.price}, Qty: {item.quantity}, Subtotal: {item_subtotal}")
logger.info(f" FINAL BASE PRICE: {base_price}")
# Создаем Sale (с автоматическим FIFO-списанием)
sale = SaleProcessor.create_sale(
product=product,
warehouse=warehouse,
quantity=sale_quantity,
sale_price=Decimal(str(item.price)),
sale_price=base_price,
order=instance,
document_number=instance.order_number,
sales_unit=item.sales_unit # Передаем sales_unit в Sale
@@ -1801,8 +1938,13 @@ def update_kit_prices_on_product_change(sender, instance, created, **kwargs):
if created:
return
# Находим все KitItem с этим товаром
kit_items = KitItem.objects.filter(product=instance)
# Находим все KitItem с этим товаром, исключая временные (витринные) комплекты
# Витринные комплекты имеют зафиксированную цену и не должны обновляться автоматически
kit_items = KitItem.objects.filter(
product=instance
).select_related('kit').exclude(
kit__is_temporary=True
)
if not kit_items.exists():
return # Товар не используется в комплектах

View File

@@ -688,7 +688,13 @@
<td><span class="badge bg-info">{{ doc.get_receipt_type_display }}</span></td>
<td class="text-muted-small">{{ doc.date|date:"d.m.Y" }}</td>
<td>{{ doc.supplier_name|default:"-" }}</td>
<td class="text-muted-small">{{ doc.created_by.name|default:doc.created_by.email|default:"-" }}</td>
<td class="text-muted-small">
{% if doc.created_by %}
{{ doc.created_by.name|default:doc.created_by.email }}
{% else %}
-
{% endif %}
</td>
<td class="text-muted-small">
{% if doc.confirmed_by %}
{{ doc.confirmed_by.name|default:doc.confirmed_by.email }} ({{ doc.confirmed_at|date:"d.m H:i" }})

View File

@@ -188,6 +188,7 @@
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Закупочная цена</th>
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Сумма</th>
<th scope="col" class="px-3 py-2">Примечания</th>
<th scope="col" class="px-3 py-2 text-end" style="width: 120px;">Текущая цена продажи</th>
{% if document.can_edit %}
<th scope="col" class="px-3 py-2" style="width: 100px;"></th>
{% endif %}
@@ -200,11 +201,19 @@
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
</td>
<td class="px-3 py-2 text-end" style="width: 120px;">
<span class="item-quantity-display">{{ item.quantity|smart_quantity }}</span>
{% if document.can_edit %}
<span class="editable-quantity"
data-item-id="{{ item.id }}"
data-current-value="{{ item.quantity }}"
title="Количество (клик для редактирования)"
style="cursor: pointer;">
{{ item.quantity|smart_quantity }}
</span>
<input type="number" class="form-control form-control-sm item-quantity-input"
value="{{ item.quantity|stringformat:'g' }}" step="0.001" min="0.001"
style="display: none; width: 100px; text-align: right; margin-left: auto;">
{% else %}
<span>{{ item.quantity|smart_quantity }}</span>
{% endif %}
</td>
<td class="px-3 py-2 text-end" style="width: 120px;">
@@ -226,6 +235,40 @@
style="display: none;">
{% endif %}
</td>
<td class="px-3 py-2 text-end" style="width: 120px;">
{% if item.product.sale_price %}
<div class="text-decoration-line-through text-muted small">{{ item.product.price|floatformat:2 }} руб.</div>
{% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %}
<strong class="text-danger editable-price"
data-product-id="{{ item.product.pk }}"
data-field="sale_price"
data-current-value="{{ item.product.sale_price }}"
title="Цена со скидкой (клик для редактирования)"
style="cursor: pointer;">
{{ item.product.sale_price|floatformat:2 }} руб.
</strong>
{% else %}
<strong class="text-danger">
{{ item.product.sale_price|floatformat:2 }} руб.
</strong>
{% endif %}
{% else %}
{% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %}
<strong class="editable-price"
data-product-id="{{ item.product.pk }}"
data-field="price"
data-current-value="{{ item.product.price }}"
title="Цена продажи (клик для редактирования)"
style="cursor: pointer;">
{{ item.product.price|floatformat:2 }} руб.
</strong>
{% else %}
<strong>
{{ item.product.price|floatformat:2 }} руб.
</strong>
{% endif %}
{% endif %}
</td>
{% if document.can_edit %}
<td class="px-3 py-2 text-end" style="width: 100px;">
<div class="btn-group btn-group-sm item-action-buttons">
@@ -256,7 +299,7 @@
</tr>
{% empty %}
<tr>
<td colspan="{% if document.can_edit %}6{% else %}5{% endif %}" class="px-3 py-4 text-center text-muted">
<td colspan="{% if document.can_edit %}7{% else %}6{% endif %}" class="px-3 py-4 text-center text-muted">
<i class="bi bi-inbox fs-3 d-block mb-2"></i>
Позиций пока нет
</td>
@@ -268,7 +311,7 @@
<tr>
<td class="px-3 py-2 fw-semibold">Итого:</td>
<td class="px-3 py-2 fw-semibold text-end">{{ document.total_quantity|smart_quantity }}</td>
<td colspan="2" class="px-3 py-2 fw-semibold text-end">{{ document.total_cost|floatformat:2 }}</td>
<td colspan="3" class="px-3 py-2 fw-semibold text-end">{{ document.total_cost|floatformat:2 }}</td>
<td colspan="{% if document.can_edit %}2{% else %}1{% endif %}"></td>
</tr>
</tfoot>
@@ -283,6 +326,7 @@
<!-- JS для компонента поиска -->
<script src="{% static 'products/js/product-search-picker.js' %}?v=3"></script>
<script src="{% static 'products/js/inline-price-edit.js' %}?v=1.5"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Элементы формы
@@ -523,7 +567,184 @@ document.addEventListener('DOMContentLoaded', function() {
saveBtn.innerHTML = '<i class="bi bi-check-lg"></i>';
});
}
// ============================================
// Inline редактирование количества
// ============================================
function initInlineQuantityEdit() {
// Проверяем, есть ли на странице редактируемые количества
const editableQuantities = document.querySelectorAll('.editable-quantity');
if (editableQuantities.length === 0) {
return; // Нет элементов для редактирования
}
// Обработчик клика на редактируемое количество
document.addEventListener('click', function(e) {
const quantitySpan = e.target.closest('.editable-quantity');
if (!quantitySpan) return;
// Предотвращаем повторное срабатывание, если уже редактируем
if (quantitySpan.querySelector('input')) return;
const itemId = quantitySpan.dataset.itemId;
const currentValue = quantitySpan.dataset.currentValue;
// Сохраняем оригинальный HTML
const originalHTML = quantitySpan.innerHTML;
// Создаем input для редактирования
const input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
input.style.width = '100px';
input.style.textAlign = 'right';
input.value = parseFloat(currentValue).toFixed(3);
input.step = '0.001';
input.min = '0.001';
input.placeholder = 'Количество';
// Заменяем содержимое на input
quantitySpan.innerHTML = '';
quantitySpan.appendChild(input);
input.focus();
input.select();
// Функция сохранения
const saveQuantity = async () => {
let newValue = input.value.trim();
// Валидация
if (!newValue || parseFloat(newValue) <= 0) {
alert('Количество должно быть больше нуля');
quantitySpan.innerHTML = originalHTML;
return;
}
// Проверяем, изменилось ли значение
if (parseFloat(newValue) === parseFloat(currentValue)) {
// Значение не изменилось
quantitySpan.innerHTML = originalHTML;
return;
}
// Показываем загрузку
input.disabled = true;
input.style.opacity = '0.5';
try {
// Получаем текущие значения других полей
const row = quantitySpan.closest('tr');
const costPrice = row.querySelector('.item-cost-price-input').value;
const notes = row.querySelector('.item-notes-input').value;
const response = await fetch(`/inventory/incoming-documents/{{ document.pk }}/update-item/${itemId}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: new URLSearchParams({
quantity: newValue,
cost_price: costPrice,
notes: notes
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.success) {
// Обновляем отображение
let formattedQty = parseFloat(newValue);
if (formattedQty === Math.floor(formattedQty)) {
formattedQty = Math.floor(formattedQty).toString();
} else {
formattedQty = formattedQty.toString().replace('.', ',');
}
quantitySpan.textContent = formattedQty;
quantitySpan.dataset.currentValue = newValue;
// Пересчитываем сумму
const totalCost = (parseFloat(newValue) * parseFloat(costPrice)).toFixed(2);
row.querySelector('td:nth-child(4) strong').textContent = totalCost;
// Обновляем итого
updateTotals();
} else {
alert(data.error || 'Ошибка при обновлении количества');
quantitySpan.innerHTML = originalHTML;
}
} catch (error) {
console.error('Error:', error);
alert('Ошибка сети при обновлении количества');
quantitySpan.innerHTML = originalHTML;
}
};
// Функция отмены
const cancelEdit = () => {
quantitySpan.innerHTML = originalHTML;
};
// Enter - сохранить
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
saveQuantity();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
// Потеря фокуса - сохранить
input.addEventListener('blur', function() {
setTimeout(saveQuantity, 100);
});
});
}
// Функция обновления итоговых сумм
function updateTotals() {
// Можно реализовать пересчет итогов, если нужно
// Пока оставим как есть, так как сервер возвращает обновленные данные
}
// Инициализация inline редактирования количества
initInlineQuantityEdit();
});
</script>
<style>
/* Стили для редактируемых цен */
.editable-price {
cursor: pointer;
transition: color 0.2s ease;
}
.editable-price:hover {
color: #0d6efd !important;
text-decoration: underline;
}
.price-edit-container {
min-height: 2.5rem;
}
/* Стили для редактируемого количества */
.editable-quantity {
cursor: pointer;
transition: color 0.2s ease;
}
.editable-quantity:hover {
color: #0d6efd !important;
text-decoration: underline;
}
</style>
{% endblock %}

View File

@@ -107,7 +107,7 @@
<h6 class="mb-0"><i class="bi bi-plus-square me-2"></i>Добавить товар в инвентаризацию</h6>
</div>
<div class="card-body">
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара для инвентаризации...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить товар' content_height='250px' %}
{% include 'products/components/product_search_picker.html' with container_id='inventory-product-picker' title='Поиск товара для инвентаризации...' warehouse_id=inventory.warehouse.id filter_in_stock_only=False categories=categories tags=tags add_button_text='Добавить товар' content_height='250px' skip_stock_filter=True %}
</div>
</div>
{% endif %}
@@ -288,11 +288,15 @@
<script src="{% static 'inventory/js/inventory_detail.js' %}" onerror="console.error('Failed to load inventory_detail.js');"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM loaded, initializing inventory components...');
// Проверка загрузки ProductSearchPicker
if (typeof ProductSearchPicker === 'undefined') {
console.error('ProductSearchPicker is not defined. Check if product-search-picker.js loaded correctly.');
console.error('Script URL: {% static "products/js/product-search-picker.js" %}');
return;
} else {
console.log('ProductSearchPicker is available');
}
// Инициализация компонента поиска товаров
@@ -303,8 +307,15 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
console.log('Initializing ProductSearchPicker for inventory...');
const picker = ProductSearchPicker.init('#inventory-product-picker', {
apiUrl: '{% url "products:api-search-products-variants" %}', // Явно указываем URL API
excludeKits: true, // Исключаем комплекты из поиска
onSelect: function(product, instance) {
console.log('Product selected:', product);
},
onAddSelected: function(product, instance) {
console.log('Adding selected product to inventory:', product);
if (product) {
addInventoryLine(product.id);
instance.clearSelection();
@@ -314,17 +325,24 @@ document.addEventListener('DOMContentLoaded', function() {
if (!picker) {
console.error('Failed to initialize ProductSearchPicker');
} else {
console.log('ProductSearchPicker initialized successfully');
}
{% else %}
console.log('Inventory is completed, skipping product picker initialization');
{% endif %}
// Инициализация обработчиков
const inventoryId = {{ inventory.pk }};
console.log('Initializing inventory detail handlers for ID:', inventoryId);
window.inventoryDetailHandlers = initInventoryDetailHandlers(inventoryId, {
addLineUrl: '{% url "inventory:inventory-line-add" inventory.pk %}',
updateLineUrl: '{% url "inventory:inventory-line-update" inventory.pk 999 %}',
deleteLineUrl: '{% url "inventory:inventory-line-delete" inventory.pk 999 %}',
completeUrl: '{% url "inventory:inventory-complete" inventory.pk %}'
});
console.log('Inventory detail handlers initialized');
});
</script>
{% endblock %}

View File

@@ -74,10 +74,12 @@
{% for item in items %}
<tr>
<td class="px-3 py-2">
<a href="{% url 'products:product-detail' item.product.id %}">{{ item.product.name }}</a>
<a href="{% url 'products:product-detail' item.product.id %}">{{
item.product.name }}</a>
</td>
<td class="px-3 py-2" style="text-align: right;">{{ item.quantity }}</td>
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.</td>
<td class="px-3 py-2" style="text-align: right;">{{ item.batch.cost_price }} ₽/ед.
</td>
<td class="px-3 py-2">
<span class="badge bg-secondary">{{ item.batch.id }}</span>
</td>
@@ -132,9 +134,11 @@
<a href="{% url 'inventory:transfer-list' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Вернуться к списку
</a>
<!--
<a href="{% url 'inventory:transfer-delete' transfer_document.id %}" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash me-1"></i>Удалить
</a>
-->
</div>
</div>
</div>

View File

@@ -39,9 +39,11 @@
<a href="{% url 'inventory:transfer-detail' t.pk %}" class="btn btn-sm btn-outline-info" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
<!--
<a href="{% url 'inventory:transfer-delete' t.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
-->
</td>
</tr>
{% endfor %}

View File

@@ -1066,10 +1066,6 @@ class OrderStatusTransitionCriticalTest(TestCase):
order.save()
order.refresh_from_db()
# Проверяем, что прошли через draft (автоматический промежуточный переход)
history = order.history.all()
self.assertGreaterEqual(history.count(), 2, "[STEP 7] Должна быть история переходов")
# Проверки после автоматического перехода
self._assert_stock_state(
available=Decimal('90.00'),

View File

@@ -1,5 +1,5 @@
"""
Отладочные view для суперюзеров.
Отладочные view для owner и manager.
Для мониторинга работы системы инвентаризации.
"""
from django.contrib.auth.decorators import login_required, user_passes_test
@@ -15,16 +15,16 @@ from products.models import Product
from inventory.models import Warehouse
def is_superuser(user):
"""Проверка что пользователь - суперюзер."""
return user.is_superuser
def is_owner_or_manager(user):
"""Проверка что пользователь - owner или manager."""
return user.is_owner or user.is_manager
@login_required
@user_passes_test(is_superuser)
@user_passes_test(is_owner_or_manager)
def debug_inventory_page(request):
"""
Отладочная страница для суперюзеров.
Отладочная страница для owner и manager.
Показывает полную картину по инвентаризации: партии, остатки, резервы, продажи.
"""
# Получаем параметры фильтров

View File

@@ -18,7 +18,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Initialize environment variables
env = environ.Env(
# Set casting and default values
DEBUG=(bool, False), # Security: default False
DEBUG=(bool, True), # Debug mode enabled
SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'),
)
@@ -631,3 +631,5 @@ if not DEBUG and not ENCRYPTION_KEY:
"ENCRYPTION_KEY not set! Encrypted fields will fail. "
"Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
)

View File

@@ -101,11 +101,19 @@ class OrderFilter(django_filters.FilterSet):
widget=forms.Select(attrs={'class': 'form-select'})
)
# Фильтр: показывать все заказы, включая завершённые и отменённые
show_all_orders = django_filters.BooleanFilter(
method='filter_show_all_orders',
label='Включая завершённые',
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
class Meta:
model = Order
fields = ['search', 'status', 'delivery_type', 'payment_status',
'delivery_date_after', 'delivery_date_before',
'created_at_after', 'created_at_before']
'created_at_after', 'created_at_before',
'show_all_orders']
def filter_search(self, queryset, name, value):
"""
@@ -134,3 +142,18 @@ class OrderFilter(django_filters.FilterSet):
elif value == 'pickup':
return queryset.filter(delivery__delivery_type=Delivery.DELIVERY_TYPE_PICKUP)
return queryset
def filter_show_all_orders(self, queryset, name, value):
"""
Фильтр для показа всех заказов.
- Если False или не передан: только активные заказы
(статусы с is_positive_end=False И is_negative_end=False)
- Если True: все заказы без ограничений
"""
if not value:
# Активные заказы = НЕ (is_positive_end OR is_negative_end)
return queryset.filter(
Q(status__isnull=True) |
(Q(status__is_positive_end=False) & Q(status__is_negative_end=False))
)
return queryset

View File

@@ -425,7 +425,8 @@ class OrderForm(forms.ModelForm):
has_address = (
(address_mode == 'history' and address_from_history) or
(address_mode == 'new' and address_street)
(address_mode == 'new' and address_street) or
address_mode == 'empty' # Разрешаем "Без адреса (заполнить позже)"
)
if not has_address:
@@ -461,11 +462,15 @@ class OrderItemForm(forms.ModelForm):
widget=forms.TextInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'})
)
# Поле DELETE, которое автоматически добавляется в inline формсете
DELETE = forms.BooleanField(required=False, widget=forms.HiddenInput())
class Meta:
model = OrderItem
fields = ['product', 'product_kit', 'sales_unit', 'quantity', 'price', 'is_custom_price', 'is_from_showcase']
# ВАЖНО: НЕ включаем 'id' в fields - это предотвращает ошибку валидации
fields = ['id', 'product', 'product_kit', 'sales_unit', 'quantity', 'price', 'is_custom_price', 'is_from_showcase']
# ВАЖНО: Теперь включаем 'id' в fields для правильной работы inline формсета
widgets = {
'id': forms.HiddenInput(), # Скрываем поле id, но оставляем его для формсета
'quantity': forms.NumberInput(attrs={'min': 1}),
# Скрываем поля product и product_kit - они будут заполняться через JS
'product': forms.HiddenInput(),

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.0.10 on 2026-01-21 07:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_order_summary'),
('products', '0001_add_sales_unit_to_kititem'),
]
operations = [
migrations.AddField(
model_name='kititemsnapshot',
name='conversion_factor',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Сколько единиц продажи в 1 базовой единице товара', max_digits=15, null=True, verbose_name='Коэффициент конверсии'),
),
migrations.AddField(
model_name='kititemsnapshot',
name='original_sales_unit',
field=models.ForeignKey(blank=True, help_text='Единица продажи на момент создания снимка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kit_item_snapshots', to='products.productsalesunit', verbose_name='Единица продажи'),
),
]

View File

@@ -168,7 +168,8 @@ class Delivery(models.Model):
'time_to': 'Время окончания доставки не может быть раньше времени начала'
})
def save(self, *args, **kwargs):
def save(self, *args, validate=True, **kwargs):
"""Переопределение save для вызова валидации"""
if validate:
self.full_clean()
super().save(*args, **kwargs)

View File

@@ -140,6 +140,25 @@ class KitItemSnapshot(models.Model):
verbose_name="Группа вариантов"
)
original_sales_unit = models.ForeignKey(
'products.ProductSalesUnit',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='kit_item_snapshots',
verbose_name="Единица продажи",
help_text="Единица продажи на момент создания снимка"
)
conversion_factor = models.DecimalField(
max_digits=15,
decimal_places=6,
null=True,
blank=True,
verbose_name="Коэффициент конверсии",
help_text="Сколько единиц продажи в 1 базовой единице товара"
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=3,

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load inventory_filters %}
{% block title %}Заказ {{ order.order_number }}{% endblock %}
@@ -337,7 +338,7 @@
<!-- Кнопка "Применить максимум" -->
<form method="post" action="{% url 'orders:apply-wallet' order.order_number %}" class="mb-2">
{% csrf_token %}
<input type="hidden" name="wallet_amount" value="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}">
<input type="hidden" name="wallet_amount" value="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|format_decimal:2 }}{% else %}{{ order.amount_due|format_decimal:2 }}{% endif %}">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-wallet2"></i> Применить максимум
</button>
@@ -351,7 +352,7 @@
type="number"
step="0.01"
min="0"
max="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}"
max="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|format_decimal:2 }}{% else %}{{ order.amount_due|format_decimal:2 }}{% endif %}"
name="wallet_amount"
class="form-control"
placeholder="Сумма"

View File

@@ -1542,6 +1542,22 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Убедимся, что все поля имеют правильные имена и ID
const fields = newForm.querySelectorAll('[name]');
fields.forEach(field => {
const name = field.getAttribute('name');
if (name && name.includes('__prefix__')) {
const newName = name.replace(/__prefix__/g, formCount);
field.setAttribute('name', newName);
}
const id = field.getAttribute('id');
if (id && id.includes('__prefix__')) {
const newId = id.replace(/__prefix__/g, formCount);
field.setAttribute('id', newId);
}
});
updateTotalDisplay();
return newForm;
@@ -1560,6 +1576,10 @@ document.addEventListener('DOMContentLoaded', function() {
// Сохранённая форма - помечаем на удаление
console.log('[removeForm] Помечаем сохранённую форму на удаление (ID:', idField.value, ')');
deleteCheckbox.checked = true;
// Также добавляем скрытое поле, чтобы гарантировать удаление
if (!deleteCheckbox.value) {
deleteCheckbox.value = 'on';
}
form.classList.add('deleted');
form.style.display = 'none';
updateTotalDisplay();
@@ -1587,8 +1607,11 @@ document.addEventListener('DOMContentLoaded', function() {
console.log(`[removeForm] Пересчёт индексов для ${remainingForms.length} оставшихся форм...`);
remainingForms.forEach((currentForm, newIndex) => {
// Находим все поля с name="items-N-..."
const fields = currentForm.querySelectorAll('[name^="items-"]');
// Обновляем data-атрибут индекса формы
currentForm.setAttribute('data-form-index', newIndex);
// Находим все поля с name="items-N-..." и select элементы
const fields = currentForm.querySelectorAll('[name^="items-"], select[name^="items-"]');
fields.forEach(field => {
const name = field.getAttribute('name');
// Меняем индекс: items-СТАРЫЙ-поле → items-НОВЫЙ-поле
@@ -1602,6 +1625,41 @@ document.addEventListener('DOMContentLoaded', function() {
const newId = field.id.replace(/^id_items-\d+/, `id_items-${newIndex}`);
field.setAttribute('id', newId);
}
// Обновляем for атрибут у label, если есть
const label = document.querySelector(`label[for="${field.id}"]`);
if (label) {
label.setAttribute('for', newId);
}
}
// Обновляем data-атрибут у select2 элементов
if (field.classList.contains('select2-order-item')) {
field.setAttribute('data-form-index', newIndex);
}
});
// Обновляем select элементы, если есть
const selects = currentForm.querySelectorAll('select');
selects.forEach(select => {
const name = select.getAttribute('name');
if (name && name.startsWith('items-')) {
const newName = name.replace(/^items-\d+/, `items-${newIndex}`);
if (name !== newName) {
select.setAttribute('name', newName);
// Обновляем ID тоже (для связи с label)
if (select.id) {
const newId = select.id.replace(/^id_items-\d+/, `id_items-${newIndex}`);
select.setAttribute('id', newId);
// Обновляем for атрибут у label, если есть
const label = document.querySelector(`label[for="${select.id}"]`);
if (label) {
label.setAttribute('for', newId);
}
}
}
}
});
});
@@ -1656,6 +1714,17 @@ document.addEventListener('DOMContentLoaded', function() {
// Валидация перед отправкой
document.getElementById('order-form').addEventListener('submit', function(e) {
// Убедимся, что все удаленные формы действительно отмечены для удаления
const deletedForms = document.querySelectorAll('.order-item-form.deleted');
deletedForms.forEach(form => {
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
if (deleteCheckbox) {
deleteCheckbox.checked = true;
// Убедимся, что значение установлено
deleteCheckbox.value = 'on';
}
});
// Заказ можно сохранить без товаров
});
@@ -1808,7 +1877,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Функция заполнения формы данными комплекта
function fillFormWithKit(form, kitData) {
if (!kitData || !kitData.kit_id || !kitData.kit_name || !kitData.kit_price) {
if (!kitData || !kitData.kit_id || !kitData.kit_name || kitData.kit_price === undefined) {
console.error('Invalid kit data:', kitData);
alert('Ошибка: неверные данные комплекта');
return;
@@ -1819,6 +1888,11 @@ document.addEventListener('DOMContentLoaded', function() {
const quantityInput = form.querySelector('[name$="-quantity"]');
const priceInput = form.querySelector('[name$="-price"]');
// ВАЖНО: Находим скрытые поля для product и product_kit
const productField = form.querySelector('[name$="-product"]');
const kitField = form.querySelector('[name$="-product_kit"]');
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
if (!kitSelect) {
console.error('Kit select not found in form');
return;
@@ -1826,7 +1900,17 @@ document.addEventListener('DOMContentLoaded', function() {
// Используем Select2 API для добавления опции
const newOption = new Option(kitData.kit_name, `kit_${kitData.kit_id}`, true, true);
$(kitSelect).append(newOption);
$(kitSelect).append(newOption).trigger('change');
// КЛЮЧЕВОЕ ИСПРАВЛЕНИЕ: Устанавливаем скрытые поля напрямую
// Это комплект, поэтому очищаем product и устанавливаем product_kit
if (productField) productField.value = '';
if (kitField) kitField.value = kitData.kit_id;
console.log('[fillFormWithKit] Установлены скрытые поля:', {
product: productField ? productField.value : 'not found',
product_kit: kitField ? kitField.value : 'not found'
});
// Устанавливаем количество и цену
if (quantityInput) quantityInput.value = '1';
@@ -1835,6 +1919,22 @@ document.addEventListener('DOMContentLoaded', function() {
priceInput.dataset.originalPrice = kitData.kit_price;
}
// Сбрасываем флаг кастомной цены
if (isCustomPriceField) {
isCustomPriceField.value = 'false';
}
// Скрываем единицы продажи для комплектов (у комплектов их нет)
const salesUnitContainer = form.querySelector('.sales-unit-container');
if (salesUnitContainer) {
salesUnitContainer.style.display = 'none';
}
// Обновляем сумму товаров
if (typeof window.updateOrderItemsTotal === 'function') {
window.updateOrderItemsTotal();
}
// Явно вызываем событие select2:select для запуска автосохранения
$(kitSelect).trigger('select2:select', {
params: {

View File

@@ -10,24 +10,21 @@
max-width: 280px;
}
.order-summary-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
word-break: break-word;
white-space: pre-wrap;
cursor: pointer;
transition: all 0.2s ease;
color: #212529;
}
.order-summary-text:hover {
color: #0d6efd;
.table td {
vertical-align: middle;
}
.order-summary-text.expanded {
-webkit-line-clamp: unset;
max-height: none;
position: relative;
z-index: 10;
.table tbody tr {
border-bottom: 2px solid #dee2e6;
}
.table tbody tr:last-child {
border-bottom: none;
}
.table tbody tr[data-edit-url] {
cursor: pointer;
}
</style>
{% endblock %}
@@ -44,7 +41,7 @@
</h5>
</div>
<div class="card-body">
<form method="get">
<form method="get" id="order-filter-form">
<div class="row g-3">
<!-- Поиск -->
<div class="col-md-3">
@@ -89,6 +86,19 @@
</div>
</div>
<!-- Тумблер "Включая завершённые" -->
<div class="row mt-3">
<div class="col-12">
<div class="form-check form-switch">
<input type="checkbox" name="show_all_orders" class="form-check-input" id="id_show_all_orders"
{% if request.GET.show_all_orders %}checked{% endif %}>
<label class="form-check-label" for="id_show_all_orders">
Включая завершённые
</label>
</div>
</div>
</div>
<!-- Календарный фильтр по дате доставки (вторая строка) -->
<div class="row mt-3">
<div class="col-12">
@@ -118,7 +128,6 @@
<table class="table table-hover">
<thead>
<tr>
<th>Номер</th>
<th>Дата</th>
<th>Время</th>
<th>Тип</th>
@@ -127,16 +136,13 @@
<th>Сумма</th>
<th>Оплата</th>
<th>Действия</th>
<th>Номер заказа</th>
</tr>
</thead>
<tbody>
{% for order in page_obj %}
<tr {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}class="table-warning"{% endif %}>
<td>
<a href="{% url 'orders:order-detail' order.order_number %}" class="text-decoration-none">
<strong>{{ order.order_number }}</strong>
</a>
</td>
<tr {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}class="table-warning"{% endif %}
data-edit-url="{% url 'orders:order-update' order.order_number %}">
<td>
{% if order.delivery_date %}
{{ order.delivery_date|date:"d.m.Y" }}
@@ -160,7 +166,7 @@
</td>
<td class="order-summary-cell">
{% if order.summary %}
<div class="order-summary-text" title="Клик для раскрытия/сворачивания">{{ order.summary|safe }}</div>
<div class="order-summary-text">{{ order.summary|safe }}</div>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
@@ -215,6 +221,11 @@
<i class="bi bi-pencil"></i>
</a>
</td>
<td>
<a href="{% url 'orders:order-detail' order.order_number %}" class="text-decoration-none">
<strong>{{ order.order_number }}</strong>
</a>
</td>
</tr>
{% endfor %}
</tbody>
@@ -381,12 +392,24 @@
});
});
// Toggle для раскрытия/сворачивания резюме заказа
document.querySelectorAll('.order-summary-text').forEach(function(el) {
el.addEventListener('click', function() {
this.classList.toggle('expanded');
// Двойной клик на строку для перехода к редактированию
document.querySelectorAll('tbody tr[data-edit-url]').forEach(function(row) {
row.addEventListener('dblclick', function() {
const editUrl = this.dataset.editUrl;
if (editUrl) {
window.location.href = editUrl;
}
});
});
// Тумблер "Включая завершённые" - автоматическая отправка формы
const showAllOrdersSwitch = document.getElementById('id_show_all_orders');
const filterForm = document.getElementById('order-filter-form');
if (showAllOrdersSwitch && filterForm) {
showAllOrdersSwitch.addEventListener('change', function() {
filterForm.submit();
});
}
})();
</script>
{% endblock %}

View File

@@ -68,26 +68,7 @@ def order_create(request):
draft_items = []
if request.method == 'POST':
# Логирование POST-данных для отладки
print("\n=== POST DATA ===")
print(f"items-TOTAL_FORMS: {request.POST.get('items-TOTAL_FORMS')}")
print(f"items-INITIAL_FORMS: {request.POST.get('items-INITIAL_FORMS')}")
print(f"items-MIN_NUM_FORMS: {request.POST.get('items-MIN_NUM_FORMS')}")
print(f"items-MAX_NUM_FORMS: {request.POST.get('items-MAX_NUM_FORMS')}")
# Показываем все формы товаров
total_forms = int(request.POST.get('items-TOTAL_FORMS', 0))
for i in range(total_forms):
product = request.POST.get(f'items-{i}-product', '')
kit = request.POST.get(f'items-{i}-product_kit', '')
quantity = request.POST.get(f'items-{i}-quantity', '')
price = request.POST.get(f'items-{i}-price', '')
print(f"\nForm {i}:")
print(f" product: {product or '(пусто)'}")
print(f" kit: {kit or '(пусто)'}")
print(f" quantity: {quantity or '(пусто)'}")
print(f" price: {price or '(пусто)'}")
print("=== END POST DATA ===\n")
form = OrderForm(request.POST)
formset = OrderItemFormSet(request.POST)
@@ -110,7 +91,11 @@ def order_create(request):
order.recipient = None
# Статус берём из формы (в том числе может быть "Черновик")
from accounts.models import CustomUser
if isinstance(request.user, CustomUser):
order.modified_by = request.user
else:
order.modified_by = None
# Сохраняем заказ в БД (теперь у него есть pk)
order.save()
@@ -175,13 +160,61 @@ def order_create(request):
# Проверяем, является ли заказ черновиком
is_draft = order.status and order.status.code == 'draft'
# Получаем данные из формы (уже провалидированы)
delivery_type = form.cleaned_data.get('delivery_type')
delivery_date = form.cleaned_data.get('delivery_date')
time_from = form.cleaned_data.get('time_from')
time_to = form.cleaned_data.get('time_to')
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
# ВАЖНО: Поля доставки НЕ включены в Meta.fields формы OrderForm,
# поэтому они не попадают в form.cleaned_data!
# Читаем их напрямую из request.POST и обрабатываем вручную
# Получаем данные доставки из POST
delivery_type = request.POST.get('delivery_type', None)
# Обрабатываем дату доставки
delivery_date_str = request.POST.get('delivery_date', None)
delivery_date = None
if delivery_date_str:
try:
from datetime import datetime
delivery_date = datetime.strptime(delivery_date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
pass
# Обрабатываем время
time_from_str = request.POST.get('time_from', None)
time_from = None
if time_from_str:
try:
from datetime import datetime
time_from = datetime.strptime(time_from_str, '%H:%M').time()
except (ValueError, TypeError):
pass
time_to_str = request.POST.get('time_to', None)
time_to = None
if time_to_str:
try:
from datetime import datetime
time_to = datetime.strptime(time_to_str, '%H:%M').time()
except (ValueError, TypeError):
pass
# Обрабатываем стоимость доставки
delivery_cost_str = request.POST.get('delivery_cost', '0')
delivery_cost = Decimal('0')
if delivery_cost_str:
try:
delivery_cost = Decimal(delivery_cost_str.replace(',', '.'))
except (ValueError, TypeError):
pass
delivery_cost = Decimal('0')
# Обрабатываем склад самовывоза
pickup_warehouse_id = request.POST.get('pickup_warehouse', None)
pickup_warehouse = None
if pickup_warehouse_id:
try:
from inventory.models import Warehouse
pickup_warehouse = Warehouse.objects.get(pk=pickup_warehouse_id)
except (Warehouse.DoesNotExist, ValueError):
pass
# Обрабатываем адрес для курьерской доставки (даже для черновиков, если указан)
address = None
@@ -323,6 +356,10 @@ def order_update(request, order_number):
form = OrderForm(request.POST, instance=order)
formset = OrderItemFormSet(request.POST, instance=order)
if form.is_valid() and formset.is_valid():
try:
with transaction.atomic():
@@ -334,24 +371,83 @@ def order_update(request, order_number):
# Сохраняем получателя: если новый - создаем, если существующий - обновляем
recipient.save() # Django автоматически определит create или update
order.recipient = recipient
else:
# Если покупатель является получателем
order.recipient = None
from accounts.models import CustomUser
if isinstance(request.user, CustomUser):
order.modified_by = request.user
else:
# Если это админ платформы, не перезаписываем поле (оставляем как есть)
pass
order.save()
formset.save()
# Проверяем, является ли заказ черновиком
is_draft = order.status and order.status.code == 'draft'
# Получаем данные из формы (уже провалидированы)
delivery_type = form.cleaned_data.get('delivery_type')
delivery_date = form.cleaned_data.get('delivery_date')
time_from = form.cleaned_data.get('time_from')
time_to = form.cleaned_data.get('time_to')
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
# ВАЖНО: Поля доставки НЕ включены в Meta.fields формы OrderForm,
# поэтому они не попадают в form.cleaned_data!
# Читаем их напрямую из request.POST и обрабатываем вручную
# Получаем данные доставки из POST
delivery_type = request.POST.get('delivery_type', None)
# Обрабатываем дату доставки
delivery_date_str = request.POST.get('delivery_date', None)
delivery_date = None
if delivery_date_str:
try:
from datetime import datetime
delivery_date = datetime.strptime(delivery_date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
pass
# Обрабатываем время
time_from_str = request.POST.get('time_from', None)
time_from = None
if time_from_str:
try:
from datetime import datetime
time_from = datetime.strptime(time_from_str, '%H:%M').time()
except (ValueError, TypeError):
print(f"[DEBUG] Error parsing time_from: {time_from_str}")
time_to_str = request.POST.get('time_to', None)
time_to = None
if time_to_str:
try:
from datetime import datetime
time_to = datetime.strptime(time_to_str, '%H:%M').time()
except (ValueError, TypeError):
pass
# Обрабатываем стоимость доставки
delivery_cost_str = request.POST.get('delivery_cost', '0')
delivery_cost = Decimal('0')
if delivery_cost_str:
try:
delivery_cost = Decimal(delivery_cost_str.replace(',', '.'))
except (ValueError, TypeError):
pass
delivery_cost = Decimal('0')
# Обрабатываем склад самовывоза
pickup_warehouse_id = request.POST.get('pickup_warehouse', None)
pickup_warehouse = None
if pickup_warehouse_id:
try:
from inventory.models import Warehouse
pickup_warehouse = Warehouse.objects.get(pk=pickup_warehouse_id)
except (Warehouse.DoesNotExist, ValueError):
pass
# Обрабатываем адрес для курьерской доставки (даже для черновиков, если указан)
address = None
@@ -366,7 +462,7 @@ def order_update(request, order_number):
if is_draft:
# Для черновиков создаем Delivery, если есть хотя бы адрес или данные доставки
if address or delivery_type or pickup_warehouse or delivery_date:
Delivery.objects.update_or_create(
delivery_obj, created = Delivery.objects.update_or_create(
order=order,
defaults={
'delivery_type': delivery_type or Delivery.DELIVERY_TYPE_COURIER,
@@ -378,8 +474,9 @@ def order_update(request, order_number):
'cost': delivery_cost if delivery_cost else Decimal('0')
}
)
elif hasattr(order, 'delivery'):
# Если заказ стал черновиком и нет данных доставки, удаляем Delivery
order.delivery.delete()
else:
# Для не-черновиков проверяем обязательные поля
@@ -409,6 +506,7 @@ def order_update(request, order_number):
}
)
# Пересчитываем итоговую стоимость
order.calculate_total()
order.update_payment_status()
@@ -429,17 +527,7 @@ def order_update(request, order_number):
# Транзакция откатилась, статус НЕ изменился
messages.error(request, f'Ошибка при сохранении заказа: {e}')
else:
# Логируем ошибки для отладки
print("\n=== ОШИБКИ ВАЛИДАЦИИ ФОРМЫ ===")
if not form.is_valid():
print(f"OrderForm errors: {form.errors}")
if not formset.is_valid():
print(f"OrderItemFormSet errors: {formset.errors}")
print(f"OrderItemFormSet non_form_errors: {formset.non_form_errors()}")
for i, item_form in enumerate(formset):
if item_form.errors:
print(f" Item form {i} errors: {item_form.errors}")
print("=== КОНЕЦ ОШИБОК ===\n")
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
else:
form = OrderForm(instance=order)

View File

@@ -32,6 +32,14 @@ body {
flex-grow: 1;
}
/* 3 колонки для товаров и категорий на экранах от 400px */
@media (min-width: 400px) {
.col-custom-3 {
flex: 0 0 33.333%;
max-width: 33.333%;
}
}
/* 5 колонок для товаров и категорий на экранах от 1100px */
@media (min-width: 1100px) {
.col-lg-custom-5 {
@@ -845,3 +853,62 @@ body {
margin-top: 90px; /* учитываем поиск и категории */
}
}
/* ============================================================
МОБИЛЬНЫЙ DROPDOWN "ЕЩЁ"
============================================================ */
/* Кнопка dropdown */
.mobile-cart-actions .dropdown-toggle {
min-width: 44px;
padding: 0.5rem;
}
/* Меню dropdown */
.mobile-cart-actions .dropdown-menu {
min-width: 180px;
font-size: 0.9rem;
}
/* Пункты меню */
.mobile-cart-actions .dropdown-item {
padding: 0.5rem 1rem;
display: flex;
align-items: center;
}
.mobile-cart-actions .dropdown-item i {
font-size: 1rem;
}
/* ============================================================
ИНТЕРАКТИВНОСТЬ СТРОКИ КОРЗИНЫ (редактирование товара)
============================================================ */
/* Интерактивность строки корзины при наведении */
.cart-item {
transition: background-color 0.15s ease;
}
.cart-item:hover {
background-color: #f8f9fa !important;
border-radius: 4px;
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
/* Исключаем hover для витринных комплектов - они сохраняют свой фон */
.cart-item[style*="background-color"]:hover {
background-color: #ffe6a0 !important; /* чуть светлее желтого */
}
/* Индикатор изменённой цены */
.cart-item.price-overridden .item-name-price .text-muted {
color: #f59e0b !important;
font-weight: 600;
}
.cart-item.price-overridden .item-name-price .text-muted::after {
content: ' *';
color: #f59e0b;
}

View File

@@ -0,0 +1,213 @@
/**
* Модуль редактирования товара в корзине POS-терминала
* Отвечает за открытие модалки и сохранение изменений
*/
(function() {
'use strict';
let editingCartKey = null;
let basePrice = 0;
/**
* Округление цены до 2 знаков
*/
function roundPrice(value) {
if (value === null || value === undefined || isNaN(value)) return '0.00';
return (Number(value)).toFixed(2);
}
/**
* Открытие модалки редактирования
* @param {string} cartKey - ключ товара в корзине
*/
function openModal(cartKey) {
const item = window.cart?.get(cartKey);
if (!item) {
console.error('CartItemEditor: Item not found for key:', cartKey);
return;
}
// Проверяем наличие модалки
const modalEl = document.getElementById('editCartItemModal');
if (!modalEl) {
console.error('CartItemEditor: Modal element not found!');
return;
}
editingCartKey = cartKey;
basePrice = parseFloat(item.price) || 0;
// Проверяем, является ли товар витринным комплектом
const isShowcaseKit = item.type === 'showcase_kit';
// Заполнение полей
document.getElementById('editModalProductName').textContent = item.name || '—';
// Используем formatMoney из terminal.js
const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2);
document.getElementById('editModalBasePrice').textContent = fmtMoney(basePrice) + ' руб.';
document.getElementById('editModalPrice').value = roundPrice(basePrice);
document.getElementById('editModalQuantity').value = item.qty || 1;
// Для витринных комплектов блокируем изменение количества
const qtyInput = document.getElementById('editModalQuantity');
const qtyHint = document.getElementById('editModalQtyHint');
if (isShowcaseKit) {
qtyInput.disabled = true;
qtyHint.style.display = 'block';
} else {
qtyInput.disabled = false;
qtyHint.style.display = 'none';
}
// Бейдж единицы измерения
const unitBadge = document.getElementById('editModalUnitBadge');
if (item.unit_name) {
unitBadge.textContent = item.unit_name;
unitBadge.style.display = 'inline-block';
} else {
unitBadge.style.display = 'none';
}
updateTotal();
// Показ модалки
const modal = new bootstrap.Modal(modalEl);
modal.show();
console.log('CartItemEditor: Modal opened for', item.name);
}
/**
* Обновление суммы в модалке
*/
function updateTotal() {
const price = parseFloat(document.getElementById('editModalPrice').value) || 0;
const qty = parseFloat(document.getElementById('editModalQuantity').value) || 0;
const fmtMoney = typeof formatMoney === 'function' ? formatMoney : (v) => Number(v).toFixed(2);
document.getElementById('editModalTotal').textContent = fmtMoney(price * qty) + ' руб.';
// Индикатор изменения цены
const warning = document.getElementById('editModalPriceWarning');
if (Math.abs(price - basePrice) > 0.01) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
}
/**
* Сохранение изменений
*/
function saveChanges() {
if (!editingCartKey) return;
const newPrice = parseFloat(document.getElementById('editModalPrice').value) || 0;
const newQty = parseFloat(document.getElementById('editModalQuantity').value) || 1;
const item = window.cart?.get(editingCartKey);
if (item) {
// Используем roundQuantity из terminal.js
const rndQty = typeof roundQuantity === 'function' ? roundQuantity : (v, d) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d);
const isShowcaseKit = item.type === 'showcase_kit';
item.price = newPrice;
// Для витринных комплектов не меняем количество
if (!isShowcaseKit) {
item.qty = rndQty(newQty, 3);
}
item.price_overridden = Math.abs(newPrice - basePrice) > 0.01;
window.cart.set(editingCartKey, item);
// Перерисовка корзины
if (typeof renderCart === 'function') {
renderCart();
}
// Сохранение на сервере
if (typeof saveCartToServer === 'function') {
saveCartToServer();
}
console.log('CartItemEditor: Changes saved for', item.name);
}
// Закрытие модалки
const modalEl = document.getElementById('editCartItemModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
}
/**
* Сброс состояния модалки
*/
function reset() {
editingCartKey = null;
basePrice = 0;
}
/**
* Инициализация модуля
*/
function init() {
const priceInput = document.getElementById('editModalPrice');
const qtyInput = document.getElementById('editModalQuantity');
const confirmBtn = document.getElementById('confirmEditCartItem');
if (!priceInput || !confirmBtn) {
console.warn('CartItemEditor: Required elements not found, deferring init...');
// Повторная попытка через короткое время
setTimeout(init, 100);
return;
}
console.log('CartItemEditor: Initialized successfully');
// Обновление суммы при изменении полей
priceInput.addEventListener('input', updateTotal);
qtyInput.addEventListener('input', updateTotal);
// Авто-выделение всего текста при фокусе
priceInput.addEventListener('focus', function() {
this.select();
});
qtyInput.addEventListener('focus', function() {
this.select();
});
// Кнопка сохранения
confirmBtn.addEventListener('click', saveChanges);
// Сброс при закрытии модалки
const modalEl = document.getElementById('editCartItemModal');
modalEl.addEventListener('hidden.bs.modal', reset);
// Enter для сохранения
priceInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') saveChanges();
});
qtyInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') saveChanges();
});
}
// Экспорт функций для использования из terminal.js
window.CartItemEditor = {
openModal: openModal,
init: init
};
console.log('CartItemEditor: Module loaded');
// Автоинициализация при загрузке
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -12,6 +12,38 @@ function roundQuantity(value, decimals = 3) {
return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
/**
* Показывает toast уведомление в правом верхнем углу
* @param {string} type - 'success' или 'error'
* @param {string} message - Текст сообщения
*/
function showToast(type, message) {
const toastId = type === 'success' ? 'orderSuccessToast' : 'orderErrorToast';
const messageId = type === 'success' ? 'toastMessage' : 'errorMessage';
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
const toastElement = document.getElementById(toastId);
const messageElement = document.getElementById(messageId);
// Устанавливаем сообщение
messageElement.textContent = message;
// Добавляем цвет фона
toastElement.classList.add(bgClass, 'text-white');
// Создаём и показываем toast (автоматически скроется через 3 секунды)
const toast = new bootstrap.Toast(toastElement, {
delay: 3000,
autohide: true
});
toast.show();
// Убираем класс цвета после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.classList.remove(bgClass, 'text-white');
}, { once: true });
}
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
let ITEMS = []; // Будем загружать через API
let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent);
@@ -19,6 +51,8 @@ let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textCo
let currentCategoryId = null;
let isShowcaseView = false;
const cart = new Map();
// Экспорт корзины для использования в других модулях
window.cart = cart;
// Переменные для пагинации
let currentPage = 1;
@@ -96,6 +130,37 @@ function formatMoney(v) {
return (Number(v)).toFixed(2);
}
/**
* Форматирует дату как относительное время в русском языке
* @param {string|null} isoDate - ISO дата или null
* @returns {string} - "0 дней", "1 день", "2 дня", "5 дней", и т.д.
*/
function formatDaysAgo(isoDate) {
if (!isoDate) return '';
const created = new Date(isoDate);
const now = new Date();
const diffMs = now - created;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// Русские формы множественного числа
const lastTwo = diffDays % 100;
const lastOne = diffDays % 10;
let suffix;
if (lastTwo >= 11 && lastTwo <= 19) {
suffix = 'дней';
} else if (lastOne === 1) {
suffix = 'день';
} else if (lastOne >= 2 && lastOne <= 4) {
suffix = 'дня';
} else {
suffix = 'дней';
}
return `${diffDays} ${suffix}`;
}
// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
/**
@@ -699,7 +764,7 @@ function renderCategories() {
// Кнопка "Витрина" - первая в ряду
const showcaseCol = document.createElement('div');
showcaseCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
showcaseCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2';
const showcaseCard = document.createElement('div');
showcaseCard.className = 'card category-card showcase-card' + (isShowcaseView ? ' active' : '');
showcaseCard.style.backgroundColor = '#fff3cd';
@@ -723,7 +788,7 @@ function renderCategories() {
// Кнопка "Все"
const allCol = document.createElement('div');
allCol.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
allCol.className = 'col-6 col-custom-3 col-md-3 col-lg-2';
const allCard = document.createElement('div');
allCard.className = 'card category-card' + (currentCategoryId === null && !isShowcaseView ? ' active' : '');
allCard.onclick = async () => {
@@ -747,7 +812,7 @@ function renderCategories() {
// Категории
CATEGORIES.forEach(cat => {
const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5';
col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5';
const card = document.createElement('div');
card.className = 'card category-card' + (currentCategoryId === cat.id && !isShowcaseView ? ' active' : '');
@@ -802,7 +867,7 @@ function renderProducts() {
filtered.forEach(item => {
const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-custom-5';
col.className = 'col-6 col-custom-3 col-md-3 col-lg-custom-5';
const card = document.createElement('div');
card.className = 'card product-card';
@@ -857,6 +922,28 @@ function renderProducts() {
openEditKitModal(item.id);
};
card.appendChild(editBtn);
// Индикатор неактуальной цены (красный кружок)
if (item.price_outdated) {
const outdatedBadge = document.createElement('div');
outdatedBadge.className = 'badge bg-danger';
outdatedBadge.style.position = 'absolute';
outdatedBadge.style.top = '5px';
outdatedBadge.style.right = '45px';
outdatedBadge.style.zIndex = '10';
outdatedBadge.style.width = '18px';
outdatedBadge.style.height = '18px';
outdatedBadge.style.padding = '0';
outdatedBadge.style.borderRadius = '50%';
outdatedBadge.style.display = 'flex';
outdatedBadge.style.alignItems = 'center';
outdatedBadge.style.justifyContent = 'center';
outdatedBadge.style.fontSize = '10px';
outdatedBadge.style.minWidth = '18px';
outdatedBadge.title = 'Цена неактуальна';
outdatedBadge.innerHTML = '!';
card.appendChild(outdatedBadge);
}
}
}
@@ -887,7 +974,7 @@ function renderProducts() {
const stock = document.createElement('div');
stock.className = 'product-stock';
// Для витринных комплектов показываем название витрины И количество (доступно/всего)
// Для витринных комплектов показываем количество (доступно/всего) и дней на витрине
if (item.type === 'showcase_kit') {
const availableCount = item.available_count || 0;
const totalCount = item.total_count || availableCount;
@@ -898,7 +985,14 @@ function renderProducts() {
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge ${badgeClass} ms-1">${badgeText}</span>${cartInfo}`;
// Добавляем отображение дней с момента создания как бейдж справа
const daysAgo = formatDaysAgo(item.showcase_created_at);
const daysBadge = daysAgo ? ` <span class="badge bg-info ms-auto">${daysAgo}</span>` : '';
stock.innerHTML = `<span class="badge ${badgeClass}" style="font-size: 0.9rem;">${badgeText}</span>${daysBadge}${cartInfo}`;
stock.style.display = 'flex';
stock.style.justifyContent = 'space-between';
stock.style.alignItems = 'center';
stock.style.color = '#856404';
stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
@@ -1270,6 +1364,13 @@ function renderCart() {
cart.forEach((item, cartKey) => {
const row = document.createElement('div');
row.className = 'cart-item mb-2';
row.style.cursor = 'pointer';
row.title = 'Нажмите для редактирования';
// Индикатор изменённой цены
if (item.price_overridden) {
row.classList.add('price-overridden');
}
// СПЕЦИАЛЬНАЯ СТИЛИЗАЦИЯ для витринных комплектов
const isShowcaseKit = item.type === 'showcase_kit';
@@ -1417,6 +1518,20 @@ function renderCart() {
row.appendChild(itemTotal);
row.appendChild(deleteBtn);
// Обработчик клика для редактирования товара
row.addEventListener('click', function (e) {
// Игнорируем клики на кнопки управления количеством и удаления
if (e.target.closest('button') || e.target.closest('input')) {
return;
}
console.log('Cart row clicked, cartKey:', cartKey, 'CartItemEditor:', typeof window.CartItemEditor);
if (window.CartItemEditor) {
window.CartItemEditor.openModal(cartKey);
} else {
console.error('CartItemEditor not available!');
}
});
list.appendChild(row);
total += item.qty * item.price;
@@ -1738,8 +1853,8 @@ async function openCreateTempKitModal() {
});
// Генерируем название по умолчанию
const now = new Date();
const defaultName = `Витрина${now.toLocaleDateString('ru-RU')} ${now.toLocaleTimeString('ru-RU', {hour: '2-digit', minute: '2-digit'})}`;
const randomSuffix = Math.floor(Math.random() * 900) + 100;
const defaultName = `Витринный букет ${randomSuffix}`;
document.getElementById('tempKitName').value = defaultName;
// Загружаем список витрин
@@ -1782,6 +1897,7 @@ async function openEditKitModal(kitId) {
id: item.product_id,
name: item.name,
price: Number(item.price),
actual_catalog_price: item.actual_catalog_price ? Number(item.actual_catalog_price) : Number(item.price),
qty: Number(item.qty),
type: 'product'
});
@@ -1791,6 +1907,19 @@ async function openEditKitModal(kitId) {
// Заполняем поля формы
document.getElementById('tempKitName').value = kit.name;
document.getElementById('tempKitDescription').value = kit.description;
// Заполняем поле даты размещения на витрине
if (kit.showcase_created_at) {
// Конвертируем ISO в формат datetime-local (YYYY-MM-DDTHH:MM)
const date = new Date(kit.showcase_created_at);
// Компенсация смещения часового пояса
const offset = date.getTimezoneOffset() * 60000;
const localDate = new Date(date.getTime() - offset);
document.getElementById('showcaseCreatedAt').value = localDate.toISOString().slice(0, 16);
} else {
document.getElementById('showcaseCreatedAt').value = '';
}
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type;
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value;
@@ -1826,6 +1955,7 @@ async function openEditKitModal(kitId) {
// По<D09F><D0BE>азываем кнопку "Разобрать" и блок добавления товаров
document.getElementById('disassembleKitBtn').style.display = 'block';
document.getElementById('writeOffKitBtn').style.display = 'block';
document.getElementById('showcaseKitQuantityBlock').style.display = 'none';
document.getElementById('addProductBlock').style.display = 'block';
@@ -1867,12 +1997,97 @@ async function openEditKitModal(kitId) {
const modal = new bootstrap.Modal(document.getElementById('createTempKitModal'));
modal.show();
// Проверяем актуальность цен (сразу после открытия)
checkPricesActual();
} catch (error) {
console.error('Error loading kit for edit:', error);
alert('Ошибка при загрузке комплекта');
}
}
// Проверка актуальности цен в витринном комплекте
function checkPricesActual() {
// Удаляем старый warning если есть
const existingWarning = document.getElementById('priceOutdatedWarning');
if (existingWarning) existingWarning.remove();
// Проверяем цены используя actual_catalog_price из tempCart (уже загружен с бэкенда)
const outdatedItems = [];
let oldTotalPrice = 0;
let newTotalPrice = 0;
tempCart.forEach((item, cartKey) => {
if (item.type === 'product' && item.actual_catalog_price !== undefined) {
const savedPrice = parseFloat(item.price);
const actualPrice = parseFloat(item.actual_catalog_price);
const qty = parseFloat(item.qty) || 1;
if (Math.abs(savedPrice - actualPrice) > 0.01) {
oldTotalPrice += savedPrice * qty;
newTotalPrice += actualPrice * qty;
outdatedItems.push({
name: item.name,
old: savedPrice,
new: actualPrice,
qty: qty
});
}
}
});
if (outdatedItems.length > 0) {
showPriceOutdatedWarning(oldTotalPrice, newTotalPrice);
}
}
// Показать warning о неактуальных ценах
function showPriceOutdatedWarning(oldTotalPrice, newTotalPrice) {
const modalBody = document.querySelector('#createTempKitModal .modal-body');
const warning = document.createElement('div');
warning.id = 'priceOutdatedWarning';
warning.className = 'alert alert-warning alert-dismissible fade show d-flex align-items-start';
warning.innerHTML = `
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-2 mt-1"></i>
<div class="flex-grow-1">
<strong>Цена неактуальна!</strong><br>
<small class="text-muted">При сохранении комплекта было: <strong>${formatMoney(oldTotalPrice)} руб.</strong></small><br>
<small class="text-muted">Актуальная цена сейчас: <strong>${formatMoney(newTotalPrice)} руб.</strong></small>
<button type="button" class="btn btn-sm btn-warning mt-2" onclick="actualizeKitPrices()">
<i class="bi bi-arrow-clockwise"></i> Пересчитать по актуальным ценам
</button>
</div>
<button type="button" class="btn-close flex-shrink-0" data-bs-dismiss="alert"></button>
`;
modalBody.insertBefore(warning, modalBody.firstChild);
}
// Актуализировать цены в комплекте
function actualizeKitPrices() {
tempCart.forEach((item) => {
if (item.type === 'product' && item.actual_catalog_price !== undefined) {
item.price = item.actual_catalog_price;
// Удаляем actual_catalog_price чтобы не показывался warning снова
delete item.actual_catalog_price;
}
});
// Отключаем чекбокс "Установить свою цену" чтобы использовать актуализированную цену
document.getElementById('useSalePrice').checked = false;
document.getElementById('salePrice').value = '';
document.getElementById('salePriceBlock').style.display = 'none';
// Перерисовываем товары и пересчитываем цену (после отключения чекбокса!)
renderTempKitItems();
updatePriceCalculations();
// Убираем warning
const warning = document.getElementById('priceOutdatedWarning');
if (warning) warning.remove();
}
// Обновление списка витринных комплектов
async function loadShowcaseKits() {
try {
@@ -2144,6 +2359,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
const kitName = document.getElementById('tempKitName').value.trim();
const showcaseId = document.getElementById('showcaseSelect').value;
const description = document.getElementById('tempKitDescription').value.trim();
const showcaseCreatedAt = document.getElementById('showcaseCreatedAt').value;
const photoFile = document.getElementById('tempKitPhoto').files[0];
// Валидация
@@ -2182,6 +2398,14 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
// Получаем количество букетов для создания
const showcaseKitQuantity = parseInt(document.getElementById('showcaseKitQuantity').value, 10) || 1;
// Вычисляем итоговую цену комплекта на основе изменённых цен в корзине
let calculatedPrice = 0;
tempCart.forEach((item) => {
if (item.type === 'product') {
calculatedPrice += item.qty * item.price;
}
});
// Формируем FormData для отправки с файлом
const formData = new FormData();
formData.append('kit_name', kitName);
@@ -2190,9 +2414,13 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
}
formData.append('description', description);
if (showcaseCreatedAt) {
formData.append('showcase_created_at', showcaseCreatedAt);
}
formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue);
// Если пользователь явно указал свою цену
if (useSalePrice && salePrice > 0) {
formData.append('sale_price', salePrice);
}
@@ -2257,6 +2485,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
// Сбрасываем поля формы
document.getElementById('tempKitDescription').value = '';
document.getElementById('showcaseCreatedAt').value = '';
document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('priceAdjustmentType').value = 'none';
@@ -2357,6 +2586,53 @@ document.getElementById('disassembleKitBtn').addEventListener('click', async ()
}
});
// Обработчик кнопки "Списать букет"
document.getElementById('writeOffKitBtn').addEventListener('click', async () => {
if (!isEditMode || !editingKitId) {
alert('Ошибка: режим редактирования не активен');
return;
}
// Запрос подтверждения
const confirmed = confirm(
'Вы уверены?\n\n' +
'Букет будет списан:\n' +
'• Будет создан документ списания с компонентами букета\n' +
'• Комплект будет помечен как "Снят"\n' +
'• Будет открыта страница документа для редактирования\n\n' +
'Продолжить?'
);
if (!confirmed) {
return;
}
try {
const response = await fetch(`/pos/api/product-kits/${editingKitId}/write-off/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken()
}
});
const data = await response.json();
if (data.success) {
// Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
modal.hide();
// Перенаправляем на страницу документа
window.location.href = data.redirect_url;
} else {
alert(`❌ Ошибка: ${data.error}`);
}
} catch (error) {
console.error('Error writing off kit:', error);
alert('Произошла ошибка при списании букета');
}
});
// Вспомогательная функция для определения мобильного устройства
function isMobileDevice() {
// Проверяем по юзер-агенту и размеру экрана
@@ -2419,8 +2695,9 @@ document.getElementById('createTempKitModal').addEventListener('hidden.bs.modal'
document.getElementById('createTempKitModalLabel').textContent = 'Создать витринный букет из корзины';
document.getElementById('confirmCreateTempKit').innerHTML = '<i class="bi bi-check-circle"></i> Создать и зарезервировать';
// Скрываем кнопку "Разобрать" и блок добавления товаров
// Скрываем кнопки "Разобрать" и "Списать" и блок добавления товаров
document.getElementById('disassembleKitBtn').style.display = 'none';
document.getElementById('writeOffKitBtn').style.display = 'none';
document.getElementById('showcaseKitQuantityBlock').style.display = 'block';
document.getElementById('addProductBlock').style.display = 'none';
}
@@ -3170,8 +3447,8 @@ async function handleCheckoutSubmit(paymentsData) {
if (result.success) {
console.log('✅ Заказ успешно создан:', result);
// Успех
alert(`Заказ #${result.order_number} успешно создан!\nСумма: ${result.total_amount.toFixed(2)} руб.`);
// Показываем toast уведомление
showToast('success', `Заказ #${result.order_number} успешно создан! Сумма: ${result.total_amount.toFixed(2)} руб.`);
// Очищаем корзину
cart.clear();
@@ -3192,12 +3469,12 @@ async function handleCheckoutSubmit(paymentsData) {
}, 500);
} else {
alert('Ошибка: ' + result.error);
showToast('error', 'Ошибка: ' + result.error);
}
} catch (error) {
console.error('Ошибка checkout:', error);
alert('Ошибка при проведении продажи: ' + error.message);
showToast('error', 'Ошибка при проведении продажи: ' + error.message);
} finally {
// Разблокируем кнопку
const btn = document.getElementById('confirmCheckoutBtn');
@@ -3392,6 +3669,30 @@ document.addEventListener('DOMContentLoaded', () => {
categoriesContent.classList.add('collapsed');
}
}
// ===== МОБИЛЬНЫЙ DROPDOWN "ЕЩЁ" =====
// Мобильная кнопка "Отложенный заказ"
const mobileScheduleLaterBtn = document.getElementById('mobileScheduleLaterBtn');
if (mobileScheduleLaterBtn) {
mobileScheduleLaterBtn.addEventListener('click', () => {
const scheduleBtn = document.getElementById('scheduleLater');
if (scheduleBtn) {
scheduleBtn.click();
}
});
}
// Мобильная кнопка "На витрину"
const mobileAddToShowcaseBtn = document.getElementById('mobileAddToShowcaseBtn');
if (mobileAddToShowcaseBtn) {
mobileAddToShowcaseBtn.addEventListener('click', () => {
const showcaseBtn = document.getElementById('addToShowcaseBtn');
if (showcaseBtn) {
showcaseBtn.click();
}
});
}
});
// Смена склада
@@ -3488,6 +3789,14 @@ searchInput.addEventListener('input', (e) => {
}, 300);
});
// При нажатии Enter на searchInput - скрываем виртуальную клавиатуру
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchInput.blur(); // Скрывает виртуальную клавиатуру на мобильных
}
});
// Обработчик кнопки очистки поиска
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';

View File

@@ -0,0 +1,66 @@
{% load static %}
<div class="modal fade" id="editCartItemModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pencil-square"></i> Редактирование товара
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Название товара -->
<div class="mb-3">
<label class="form-label text-muted small">Товар</label>
<div id="editModalProductName" class="fw-semibold"></div>
</div>
<!-- Базовая цена (оригинальная) -->
<div class="mb-3">
<label class="form-label text-muted small">Базовая цена</label>
<div class="d-flex align-items-center gap-2">
<span id="editModalBasePrice" class="text-muted">0.00 руб.</span>
<span id="editModalUnitBadge" class="badge bg-secondary" style="display: none;"></span>
</div>
</div>
<!-- Новая цена -->
<div class="mb-3">
<label for="editModalPrice" class="form-label fw-semibold">Цена за единицу</label>
<div class="input-group">
<input type="number" class="form-control" id="editModalPrice"
min="0" step="0.01" placeholder="0.00">
<span class="input-group-text">руб.</span>
</div>
<div id="editModalPriceWarning" class="text-warning small mt-1" style="display: none;">
<i class="bi bi-exclamation-triangle"></i> Цена изменена
</div>
</div>
<!-- Количество -->
<div class="mb-3">
<label for="editModalQuantity" class="form-label fw-semibold">Количество</label>
<input type="number" class="form-control" id="editModalQuantity"
min="0.001" step="0.001" value="1">
<div id="editModalQtyHint" class="text-muted small mt-1" style="display: none;">
<i class="bi bi-info-circle"></i> Количество нельзя изменить для витринного комплекта (собранный товар с резервами)
</div>
</div>
<!-- Итого -->
<div class="alert alert-info mb-0">
<div class="d-flex justify-content-between align-items-center">
<strong>Сумма:</strong>
<span class="fs-5" id="editModalTotal">0.00 руб.</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="confirmEditCartItem">
<i class="bi bi-check-lg"></i> Сохранить
</button>
</div>
</div>
</div>
</div>

View File

@@ -142,6 +142,26 @@
<button class="btn btn-outline-secondary btn-sm" id="mobileClearCartBtn" title="Очистить корзину">
<i class="bi bi-trash"></i>
</button>
<!-- Dropdown "Ещё" -->
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button"
id="mobileMoreBtn" data-bs-toggle="dropdown">
<i class="bi bi-three-dots"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" id="mobileScheduleLaterBtn" type="button">
<i class="bi bi-calendar2 me-2"></i>Отложенный заказ
</button>
</li>
<li>
<button class="dropdown-item" id="mobileAddToShowcaseBtn" type="button">
<i class="bi bi-flower1 me-2"></i>На витрину
</button>
</li>
</ul>
</div>
</div>
</div>
@@ -199,6 +219,14 @@
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
</div>
<!-- Дата размещения на витрине -->
<div class="mb-3">
<label for="showcaseCreatedAt" class="form-label">Дата размещения на витрине</label>
<input type="datetime-local" class="form-control" id="showcaseCreatedAt"
placeholder="Выберите дату и время">
<small class="text-muted">Оставьте пустым для текущего времени</small>
</div>
<!-- Загрузка фото -->
<div class="mb-3">
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
@@ -302,6 +330,11 @@
<i class="bi bi-scissors"></i> Разобрать букет
</button>
<!-- Кнопка "Списать" (отображается только в режиме редактирования) -->
<button type="button" class="btn btn-warning me-auto" id="writeOffKitBtn" style="display: none;">
<i class="bi bi-file-earmark-x"></i> Списать букет
</button>
<!-- Правая группа кнопок -->
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="confirmCreateTempKit">
@@ -693,6 +726,31 @@
</div>
</div>
</div>
<!-- Модалка редактирования товара в корзине -->
{% include 'pos/components/edit_cart_item_modal.html' %}
<!-- Toast Container для уведомлений -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1060;">
<div id="orderSuccessToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-check-circle-fill text-success me-2 fs-5"></i>
<span id="toastMessage"></span>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" style="display: none;"></button>
</div>
</div>
<div id="orderErrorToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-exclamation-circle-fill text-danger me-2 fs-5"></i>
<span id="errorMessage"></span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@@ -712,4 +770,5 @@
<script src="{% static 'products/js/product-search-picker.js' %}"></script>
<script src="{% static 'pos/js/terminal.js' %}"></script>
<script src="{% static 'pos/js/cart-item-editor.js' %}"></script>
{% endblock %}

View File

@@ -33,6 +33,8 @@ urlpatterns = [
path('api/product-kits/<int:kit_id>/update/', views.update_product_kit, name='update-product-kit'),
# Разобрать витринный комплект (освободить резервы, установить статус discontinued) [POST]
path('api/product-kits/<int:kit_id>/disassemble/', views.disassemble_product_kit, name='disassemble-product-kit'),
# Списать витринный комплект (создать документ списания с компонентами) [POST]
path('api/product-kits/<int:kit_id>/write-off/', views.write_off_showcase_kit, name='write-off-showcase-kit'),
# Создать временный комплект и зарезервировать на витрину [POST]
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
# Создать заказ и провести оплату в POS [POST]

View File

@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.db import transaction
from django.db.models import Prefetch, OuterRef, Subquery, DecimalField
from django.db.models import Prefetch, OuterRef, Subquery, DecimalField, F, Case, When, CharField
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.core.exceptions import ValidationError
@@ -13,8 +14,9 @@ import json
import logging
from products.models import Product, ProductCategory, ProductKit, KitItem
from inventory.models import Showcase, Reservation, Warehouse, Stock
from inventory.models import Showcase, Reservation, Warehouse, Stock, ShowcaseItem
from inventory.services import ShowcaseManager
from inventory.signals import skip_sale_creation, reset_sale_creation
logger = logging.getLogger(__name__)
@@ -81,6 +83,8 @@ def get_showcase_kits_for_pos():
'product_kit__sku',
'product_kit__price',
'product_kit__sale_price',
'product_kit__base_price',
'product_kit__showcase_created_at',
'showcase_id',
'showcase__name'
).annotate(
@@ -109,6 +113,19 @@ def get_showcase_kits_for_pos():
thumbnail_url = None
kit_photos[photo.kit_id] = thumbnail_url
# Загружаем состав комплектов для проверки актуальности цен
kit_items_data = {}
for ki in KitItem.objects.filter(kit_id__in=kit_ids).select_related('product'):
if ki.kit_id not in kit_items_data:
kit_items_data[ki.kit_id] = []
kit_items_data[ki.kit_id].append(ki)
# Считаем актуальные цены для каждого комплекта
kit_actual_prices = {}
for kit_id, items in kit_items_data.items():
actual_price = sum((ki.product.actual_price or 0) * (ki.quantity or 0) for ki in items)
kit_actual_prices[kit_id] = actual_price
# Формируем результат
showcase_kits = []
for item in all_items:
@@ -125,6 +142,11 @@ def get_showcase_kits_for_pos():
# Определяем актуальную цену
price = item['product_kit__sale_price'] or item['product_kit__price']
# Проверяем актуальность цены (сравниваем сохранённую цену с актуальной ценой товаров)
actual_price = kit_actual_prices.get(kit_id, Decimal('0'))
base_price = item['product_kit__base_price']
price_outdated = base_price and abs(float(base_price) - float(actual_price)) > 0.01
showcase_kits.append({
'id': kit_id,
'name': item['product_kit__name'],
@@ -139,7 +161,11 @@ def get_showcase_kits_for_pos():
# Количества
'available_count': item['available_count'], # Сколько можно добавить
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
'showcase_item_ids': available_item_ids # IDs только доступных
'showcase_item_ids': available_item_ids, # IDs только доступных
# Флаг неактуальной цены
'price_outdated': price_outdated,
# Дата размещения на витрине
'showcase_created_at': item.get('product_kit__showcase_created_at')
})
return showcase_kits
@@ -241,6 +267,8 @@ def pos_terminal(request):
if showcase_item_ids:
# Проверяем, что все указанные ShowcaseItem заблокированы на текущего пользователя
from accounts.models import CustomUser
if isinstance(request.user, CustomUser):
locked_items = ShowcaseItem.objects.filter(
id__in=showcase_item_ids,
product_kit=kit,
@@ -248,6 +276,16 @@ def pos_terminal(request):
locked_by_user=request.user,
cart_lock_expires_at__gt=timezone.now()
)
else:
# Для PlatformAdmin используем проверку по сессии
session_id = request.session.session_key or ''
locked_items = ShowcaseItem.objects.filter(
id__in=showcase_item_ids,
product_kit=kit,
status='in_cart',
cart_session_id=session_id,
cart_lock_expires_at__gt=timezone.now()
)
locked_count = locked_items.count()
@@ -454,10 +492,18 @@ def get_showcase_kits_api(request):
product_kit_id__in=kit_ids,
cart_lock_expires_at__gt=timezone.now(),
status='reserved'
).select_related('locked_by_user').values(
).select_related('locked_by_user').annotate(
# На уровне БД выбираем: если name есть - берем name, иначе email
locked_by_user_display=Case(
When(locked_by_user__name__isnull=False, then=F('locked_by_user__name')),
When(locked_by_user__name='', then=F('locked_by_user__email')),
default=F('locked_by_user__email'),
output_field=CharField()
)
).values(
'product_kit_id',
'locked_by_user_id',
'locked_by_user__username',
'locked_by_user_display',
'cart_lock_expires_at'
)
@@ -476,7 +522,7 @@ def get_showcase_kits_api(request):
is_locked_by_me = lock_info['locked_by_user_id'] == request.user.id
kit['is_locked'] = True
kit['locked_by_me'] = is_locked_by_me
kit['locked_by_user'] = lock_info['locked_by_user__username']
kit['locked_by_user'] = lock_info['locked_by_user_display']
kit['lock_expires_at'] = lock_info['cart_lock_expires_at'].isoformat()
else:
kit['is_locked'] = False
@@ -625,11 +671,22 @@ def remove_showcase_kit_from_cart(request, kit_id):
showcase_item_ids = []
# Базовый фильтр - экземпляры этого комплекта, заблокированные текущим пользователем
from accounts.models import CustomUser
if isinstance(request.user, CustomUser):
qs = ShowcaseItem.objects.filter(
product_kit=kit,
status='in_cart',
locked_by_user=request.user
)
else:
# Для PlatformAdmin используем проверку по сессии
session_id = request.session.session_key or ''
qs = ShowcaseItem.objects.filter(
product_kit=kit,
status='in_cart',
cart_session_id=session_id
)
# Если указаны конкретные ID - фильтруем только их
if showcase_item_ids:
@@ -680,10 +737,22 @@ def release_all_my_showcase_locks(request):
try:
# Снимаем ВСЕ блокировки текущего пользователя
updated_count = ShowcaseItem.objects.filter(
from accounts.models import CustomUser
if isinstance(request.user, CustomUser):
qs_to_release = ShowcaseItem.objects.filter(
status='in_cart',
locked_by_user=request.user
).update(
)
else:
# Для PlatformAdmin фильтруем по сессии
session_id = request.session.session_key or ''
qs_to_release = ShowcaseItem.objects.filter(
status='in_cart',
cart_session_id=session_id
)
updated_count = qs_to_release.update(
status='available',
locked_by_user=None,
cart_lock_expires_at=None,
@@ -948,12 +1017,21 @@ def get_product_kit_details(request, kit_id):
showcase_id = showcase_reservation.showcase.id if showcase_reservation else None
# Собираем данные о составе
items = [{
# Используем unit_price если есть (зафиксированная цена), иначе актуальную цену товара
items = []
for ki in kit.kit_items.all():
# Зафиксированная цена или актуальная цена товара
item_price = ki.unit_price if ki.unit_price is not None else ki.product.actual_price
item_data = {
'product_id': ki.product.id,
'name': ki.product.name,
'qty': str(ki.quantity),
'price': str(ki.product.actual_price)
} for ki in kit.kit_items.all()]
'price': str(item_price)
}
# Для временных комплектов добавляем актуальную цену из каталога для сравнения
if kit.is_temporary and ki.unit_price is not None:
item_data['actual_catalog_price'] = str(ki.product.actual_price)
items.append(item_data)
# Фото (используем миниатюру для быстрой загрузки)
photo_url = None
@@ -978,7 +1056,8 @@ def get_product_kit_details(request, kit_id):
'final_price': str(kit.actual_price),
'showcase_id': showcase_id,
'items': items,
'photo_url': photo_url
'photo_url': photo_url,
'showcase_created_at': kit.showcase_created_at.isoformat() if kit.showcase_created_at else None
}
})
except ProductKit.DoesNotExist:
@@ -1013,6 +1092,7 @@ def create_temp_kit_to_showcase(request):
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
# Парсим items из JSON
items = json.loads(items_json)
@@ -1027,6 +1107,23 @@ def create_temp_kit_to_showcase(request):
except (ValueError, InvalidOperation):
sale_price = None
# Showcase created at (опционально)
showcase_created_at = None
if showcase_created_at_str:
try:
from datetime import datetime
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
except ValueError:
try:
from datetime import datetime
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
except ValueError:
pass # Неверный формат, оставляем как None
# Если не указана - устанавливаем текущее время для новых комплектов
if not showcase_created_at:
showcase_created_at = timezone.now()
# Валидация
if not kit_name:
return JsonResponse({
@@ -1087,15 +1184,18 @@ def create_temp_kit_to_showcase(request):
price_adjustment_type=price_adjustment_type,
price_adjustment_value=price_adjustment_value,
sale_price=sale_price,
showcase=showcase
showcase=showcase,
showcase_created_at=showcase_created_at
)
# 2. Создаём KitItem для каждого товара из корзины
for product_id, quantity in aggregated_items.items():
product = products[product_id]
KitItem.objects.create(
kit=kit,
product=products[product_id],
quantity=quantity
product=product,
quantity=quantity,
unit_price=product.actual_price # Фиксируем цену для временного комплекта
)
# 3. Пересчитываем цену комплекта
@@ -1220,6 +1320,7 @@ def update_product_kit(request, kit_id):
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
remove_photo = request.POST.get('remove_photo', '') == '1'
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
items = json.loads(items_json)
@@ -1232,6 +1333,23 @@ def update_product_kit(request, kit_id):
except (ValueError, InvalidOperation):
sale_price = None
# Showcase created at (опционально)
showcase_created_at = None
if showcase_created_at_str:
try:
from datetime import datetime
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
except ValueError:
try:
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
except ValueError:
pass # Неверный формат, оставляем как есть
# Делаем datetime timezone-aware
if showcase_created_at and showcase_created_at.tzinfo is None:
from django.utils import timezone
showcase_created_at = timezone.make_aware(showcase_created_at)
# Валидация
if not kit_name:
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
@@ -1305,15 +1423,19 @@ def update_product_kit(request, kit_id):
kit.price_adjustment_type = price_adjustment_type
kit.price_adjustment_value = price_adjustment_value
kit.sale_price = sale_price
if showcase_created_at is not None: # Обновляем только если передана
kit.showcase_created_at = showcase_created_at
kit.save()
# Обновляем состав
kit.kit_items.all().delete()
for product_id, quantity in aggregated_items.items():
product = products[product_id]
KitItem.objects.create(
kit=kit,
product=products[product_id],
quantity=quantity
product=product,
quantity=quantity,
unit_price=product.actual_price # Фиксируем актуальную цену
)
kit.recalculate_base_price()
@@ -1415,6 +1537,88 @@ def disassemble_product_kit(request, kit_id):
}, status=500)
@login_required
@require_http_methods(["POST"])
def write_off_showcase_kit(request, kit_id):
"""
Списывает витринный комплект с созданием документа списания.
Args:
request: HTTP запрос
kit_id: ID комплекта для списания
Returns:
JSON: {
'success': bool,
'document_id': int,
'document_number': str,
'redirect_url': str,
'message': str,
'error': str (если failed)
}
"""
try:
# Получаем комплект с витриной (только временные комплекты)
kit = ProductKit.objects.select_related('showcase').get(id=kit_id, is_temporary=True)
# Проверяем, что комплект ещё не разобран
if kit.status == 'discontinued':
return JsonResponse({
'success': False,
'error': 'Комплект уже разобран (статус: Снят)'
}, status=400)
# Проверяем, что у комплекта есть привязанная витрина
if not kit.showcase:
return JsonResponse({
'success': False,
'error': 'Комплект не привязан к витрине'
}, status=400)
# Находим экземпляр на витрине
showcase_item = ShowcaseItem.objects.filter(
showcase=kit.showcase,
product_kit=kit,
status='available'
).first()
if not showcase_item:
return JsonResponse({
'success': False,
'error': 'Экземпляр комплекта не найден на витрине'
}, status=404)
# Создаём документ списания
result = ShowcaseManager.write_off_from_showcase(
showcase_item=showcase_item,
reason='spoilage',
notes=f'Витринный букет: {kit.name}',
created_by=request.user
)
if not result['success']:
return JsonResponse({
'success': False,
'error': result['message']
}, status=400)
# Формируем URL для перенаправления
redirect_url = reverse('inventory:writeoff-document-detail', kwargs={'pk': result['document_id']})
return JsonResponse({
'success': True,
'document_id': result['document_id'],
'document_number': result['document_number'],
'redirect_url': redirect_url,
'message': result['message']
})
except ProductKit.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
except Exception as e:
return JsonResponse({'success': False, 'error': f'Ошибка: {str(e)}'}, status=500)
@login_required
@require_http_methods(["POST"])
def pos_checkout(request):
@@ -1484,6 +1688,10 @@ def pos_checkout(request):
# Атомарная операция
with db_transaction.atomic():
# ВАЖНО: Устанавливаем флаг для пропуска автоматического создания Sale в сигнале.
# Sale будет создан ЯВНО после применения всех скидок.
skip_sale_creation()
# 1. Создаём заказ с текущей датой и временем в локальном часовом поясе (Europe/Minsk)
from django.utils import timezone as tz
from orders.models import Delivery
@@ -1672,6 +1880,11 @@ def pos_checkout(request):
cart_key = f'pos:cart:{request.user.id}:{warehouse_id}'
cache.delete(cart_key)
# 7. Явно создаём Sale после применения всех скидок
# Сбрасываем флаг пропуска и вызываем save() для активации сигнала
reset_sale_creation()
order.save() # Триггерит сигнал create_sale_on_order_completion
return JsonResponse({
'success': True,
'order_number': order.order_number,

View File

@@ -13,6 +13,7 @@ from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter, CostPriceHistory
from .models import ConfigurableProduct, ConfigurableProductOption, ConfigurableProductAttribute
from .models import UnitOfMeasure, ProductSalesUnit
from .models import BouquetName
from .admin_displays import (
format_quality_badge,
format_quality_display,
@@ -500,8 +501,8 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
cost_price_details_display.short_description = 'Себестоимость товара'
def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем товарам (включая удаленные)"""
qs = Product.all_objects.all()
"""Переопределяем queryset для доступа ко всем товарам"""
qs = super().get_queryset(request)
ordering = self.get_ordering(request)
if ordering:
qs = qs.order_by(*ordering)
@@ -1086,3 +1087,42 @@ class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
count
)
get_options_count.short_description = 'Вариантов'
@admin.register(BouquetName)
class BouquetNameAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
"""
Административный интерфейс для управления названиями букетов
"""
list_display = ('name', 'language', 'is_approved', 'usage_count', 'generated_at')
list_filter = ('language', 'is_approved')
search_fields = ('name',)
filter_horizontal = ('color_tags', 'occasion_tags', 'style_tags')
actions = ['approve_selected', 'reject_selected']
fieldsets = (
('Основная информация', {
'fields': ('name', 'language', 'is_approved')
}),
('Теги', {
'fields': ('color_tags', 'occasion_tags', 'style_tags')
}),
('Статистика', {
'fields': ('usage_count', 'generated_at', 'approved_at')
}),
)
readonly_fields = ('usage_count', 'generated_at', 'approved_at')
def approve_selected(self, request, queryset):
from django.db import models
queryset.update(is_approved=True, approved_at=models.DateTimeField(auto_now=True))
self.message_user(request, "Выбранные названия были одобрены")
approve_selected.short_description = "Одобрить выбранные названия"
def reject_selected(self, request, queryset):
queryset.update(is_approved=False, approved_at=None)
self.message_user(request, "Выбранные названия были отклонены")
reject_selected.short_description = "Отклонить выбранные названия"

View File

@@ -313,15 +313,17 @@ class KitItemForm(forms.ModelForm):
"""
class Meta:
model = KitItem
fields = ['product', 'variant_group', 'quantity']
fields = ['product', 'variant_group', 'sales_unit', 'quantity']
labels = {
'product': 'Конкретный товар',
'variant_group': 'Группа вариантов',
'sales_unit': 'Единица продажи',
'quantity': 'Количество'
}
widgets = {
'product': forms.Select(attrs={'class': 'form-control'}),
'variant_group': forms.Select(attrs={'class': 'form-control'}),
'sales_unit': forms.Select(attrs={'class': 'form-control'}),
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}),
}
@@ -335,25 +337,36 @@ class KitItemForm(forms.ModelForm):
cleaned_data = super().clean()
product = cleaned_data.get('product')
variant_group = cleaned_data.get('variant_group')
sales_unit = cleaned_data.get('sales_unit')
quantity = cleaned_data.get('quantity')
# Если оба поля пусты - это пустая форма (не валидируем, она будет удалена)
if not product and not variant_group:
# Подсчитываем, сколько полей заполнено
filled_fields = sum([bool(product), bool(variant_group), bool(sales_unit)])
# Если все поля пусты - это пустая форма (не валидируем, она будет удалена)
if filled_fields == 0:
# Для пустых форм обнуляем количество
cleaned_data['quantity'] = None
return cleaned_data
# Валидация: должен быть указан либо product, либо variant_group (но не оба)
if product and variant_group:
# Валидация несовместимых полей
if variant_group and (product or sales_unit):
raise forms.ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
"Нельзя указывать группу вариантов одновременно с товаром или единицей продажи."
)
# Валидация: если выбран товар/группа, количество обязательно и должно быть > 0
if (product or variant_group):
# Если выбрана единица продажи, товар обязателен
if sales_unit and not product:
raise forms.ValidationError("Для единицы продажи должен быть выбран товар.")
# Валидация: если выбран товар/группа/единица продажи, количество обязательно и должно быть > 0
if not quantity or quantity <= 0:
raise forms.ValidationError('Необходимо указать количество больше 0')
# Валидация: если выбрана единица продажи, проверяем, что она принадлежит выбранному продукту
if sales_unit and product and sales_unit.product != product:
raise forms.ValidationError('Выбранная единица продажи не принадлежит указанному товару.')
return cleaned_data
@@ -367,6 +380,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
products = []
variant_groups = []
sales_units = []
for form in self.forms:
if self.can_delete and self._should_delete_form(form):
@@ -374,6 +388,7 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
product = form.cleaned_data.get('product')
variant_group = form.cleaned_data.get('variant_group')
sales_unit = form.cleaned_data.get('sales_unit')
# Проверка дубликатов товаров
if product:
@@ -393,13 +408,22 @@ class BaseKitItemFormSet(forms.BaseInlineFormSet):
)
variant_groups.append(variant_group)
# Проверка дубликатов единиц продажи
if sales_unit:
if sales_unit in sales_units:
raise forms.ValidationError(
f'Единица продажи "{sales_unit.name}" добавлена в комплект более одного раза. '
f'Каждая единица продажи может быть добавлена только один раз.'
)
sales_units.append(sales_unit)
# Формсет для создания комплектов (с пустой формой для удобства)
KitItemFormSetCreate = inlineformset_factory(
ProductKit,
KitItem,
form=KitItemForm,
formset=BaseKitItemFormSet,
fields=['product', 'variant_group', 'quantity'],
fields=['product', 'variant_group', 'sales_unit', 'quantity'],
extra=1, # Показать 1 пустую форму для первого компонента
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
@@ -413,7 +437,7 @@ KitItemFormSetUpdate = inlineformset_factory(
KitItem,
form=KitItemForm,
formset=BaseKitItemFormSet,
fields=['product', 'variant_group', 'quantity'],
fields=['product', 'variant_group', 'sales_unit', 'quantity'],
extra=0, # НЕ показывать пустые формы при редактировании
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов

View File

@@ -0,0 +1,25 @@
# Generated migration file for adding sales_unit field to KitItem model
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('products', '0005_base_unit_nullable'),
]
operations = [
migrations.AddField(
model_name='kititem',
name='sales_unit',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='kit_items',
to='products.productsalesunit',
verbose_name='Единица продажи'
),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0.10 on 2026-01-22 10:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_add_sales_unit_to_kititem'),
]
operations = [
migrations.CreateModel(
name='BouquetName',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Название букета')),
('language', models.CharField(default='russian', max_length=10, verbose_name='Язык')),
('is_approved', models.BooleanField(default=False, verbose_name='Одобрено для использования')),
('usage_count', models.PositiveIntegerField(default=0, verbose_name='Количество использований')),
('generated_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата генерации')),
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата одобрения')),
('color_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_color', to='products.producttag', verbose_name='Цветные теги')),
('occasion_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_occasion', to='products.producttag', verbose_name='Теги по поводу')),
('style_tags', models.ManyToManyField(blank=True, related_name='bouquet_names_by_style', to='products.producttag', verbose_name='Теги по стилю')),
],
options={
'verbose_name': 'Название букета',
'verbose_name_plural': 'Названия букетов',
'indexes': [models.Index(fields=['language', 'is_approved'], name='products_bo_languag_8622de_idx'), models.Index(fields=['usage_count'], name='products_bo_usage_c_4ce5b8_idx')],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.10 on 2026-01-23 22:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_bouquetname'),
]
operations = [
migrations.AddField(
model_name='productkit',
name='showcase_created_at',
field=models.DateTimeField(blank=True, help_text='Дата создания букета для витрины (редактируемая)', null=True, verbose_name='Дата размещения на витрине'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.10 on 2026-01-19 12:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0003_remove_unit_from_sales_unit'),
]
operations = [
migrations.AddField(
model_name='kititem',
name='unit_price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Если задана, используется эта цена вместо актуальной цены товара. Применяется для временных витринных комплектов.', max_digits=10, null=True, verbose_name='Цена за единицу (зафиксированная)'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.10 on 2026-01-20 21:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0004_add_unit_price_to_kit_item'),
]
operations = [
migrations.AlterField(
model_name='product',
name='base_unit',
field=models.ForeignKey(blank=True, help_text='Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='products.unitofmeasure', verbose_name='Базовая единица'),
),
]

View File

@@ -49,6 +49,9 @@ from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPho
# Задачи импорта
from .import_job import ProductImportJob
# Названия букетов
from .bouquet_names import BouquetName
# Явно указываем, что экспортируется при импорте *
__all__ = [
# Managers
@@ -98,4 +101,7 @@ __all__ = [
# Import Jobs
'ProductImportJob',
# Bouquet Names
'BouquetName',
]

View File

@@ -0,0 +1,73 @@
from django.db import models
from .categories import ProductTag
class BouquetName(models.Model):
"""
Модель для хранения предопределенных названий букетов с метаинформацией
"""
name = models.CharField(
max_length=100,
unique=True,
verbose_name="Название букета"
)
# Категории характеристик
color_tags = models.ManyToManyField(
ProductTag,
blank=True,
related_name='bouquet_names_by_color',
verbose_name="Цветные теги"
)
occasion_tags = models.ManyToManyField(
ProductTag,
blank=True,
related_name='bouquet_names_by_occasion',
verbose_name="Теги по поводу"
)
style_tags = models.ManyToManyField(
ProductTag,
blank=True,
related_name='bouquet_names_by_style',
verbose_name="Теги по стилю"
)
language = models.CharField(
max_length=10,
default='russian',
verbose_name="Язык"
)
is_approved = models.BooleanField(
default=False,
verbose_name="Одобрено для использования"
)
usage_count = models.PositiveIntegerField(
default=0,
verbose_name="Количество использований"
)
generated_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата генерации"
)
approved_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата одобрения"
)
class Meta:
verbose_name = "Название букета"
verbose_name_plural = "Названия букетов"
indexes = [
models.Index(fields=['language', 'is_approved']),
models.Index(fields=['usage_count']),
]
def __str__(self):
return self.name

View File

@@ -93,6 +93,14 @@ class ProductKit(BaseProductEntity):
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
)
# Showcase creation date - editable date for when the bouquet was put on display
showcase_created_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата размещения на витрине",
help_text="Дата создания букета для витрины (редактируемая)"
)
order = models.ForeignKey(
'orders.Order',
on_delete=models.SET_NULL,
@@ -162,13 +170,21 @@ class ProductKit(BaseProductEntity):
total = Decimal('0')
for item in self.kit_items.all():
if item.product:
actual_price = item.product.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
if item.sales_unit:
# Для sales_unit используем цену единицы продажи
unit_price = item.sales_unit.actual_price or Decimal('0')
total += unit_price * qty
elif item.product:
# Используем зафиксированную цену если есть, иначе актуальную цену товара
if item.unit_price is not None:
unit_price = item.unit_price
else:
unit_price = item.product.actual_price or Decimal('0')
total += unit_price * qty
elif item.variant_group:
# Для variant_group unit_price не используется (только для продуктов)
actual_price = item.variant_group.price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
self.base_price = total
@@ -209,7 +225,11 @@ class ProductKit(BaseProductEntity):
# Пересчитаем базовую цену из компонентов
total = Decimal('0')
for item in self.kit_items.all():
if item.product:
if item.sales_unit:
actual_price = item.sales_unit.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
elif item.product:
actual_price = item.product.actual_price or Decimal('0')
qty = item.quantity or Decimal('1')
total += actual_price * qty
@@ -297,7 +317,12 @@ class ProductKit(BaseProductEntity):
min_available = kits_from_this_component
# Возвращаем целую часть (нельзя собрать половину комплекта)
return Decimal(int(min_available)) if min_available is not None else Decimal('0')
# Нельзя собрать отрицательное количество комплектов
if min_available is not None:
if min_available <= 0:
return Decimal('0')
return Decimal(int(min_available))
return Decimal('0')
def make_permanent(self):
"""
@@ -315,17 +340,6 @@ class ProductKit(BaseProductEntity):
self.save(update_fields=['is_temporary', 'order'])
return True
def delete(self, *args, **kwargs):
"""Soft delete вместо hard delete - марк как удаленный"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=['is_deleted', 'deleted_at'])
return 1, {self.__class__._meta.label: 1}
def hard_delete(self):
"""Полное удаление из БД (необратимо!)"""
super().delete()
def create_snapshot(self):
"""
Создает снимок текущего состояния комплекта.
@@ -365,6 +379,8 @@ class ProductKit(BaseProductEntity):
product_sku=item.product.sku if item.product else '',
product_price=product_price,
variant_group_name=item.variant_group.name if item.variant_group else '',
original_sales_unit=item.sales_unit,
conversion_factor=item.sales_unit.conversion_factor if item.sales_unit else None,
quantity=item.quantity or Decimal('1'),
)
@@ -373,8 +389,8 @@ class ProductKit(BaseProductEntity):
class KitItem(models.Model):
"""
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
Позиция может быть либо конкретным товаром, либо группой вариантов.
Состав комплекта: связь между ProductKit и Product, ProductVariantGroup или ProductSalesUnit.
Позиция может быть либо конкретным товаром, либо группой вариантов, либо конкретной единицей продажи.
"""
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
verbose_name="Комплект")
@@ -394,7 +410,23 @@ class KitItem(models.Model):
related_name='kit_items',
verbose_name="Группа вариантов"
)
sales_unit = models.ForeignKey(
'ProductSalesUnit',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='kit_items',
verbose_name="Единица продажи"
)
quantity = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True, verbose_name="Количество")
unit_price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="Цена за единицу (зафиксированная)",
help_text="Если задана, используется эта цена вместо актуальной цены товара. Применяется для временных витринных комплектов."
)
class Meta:
verbose_name = "Компонент комплекта"
@@ -411,21 +443,46 @@ class KitItem(models.Model):
return f"{self.kit.name} - {self.get_display_name()}"
def clean(self):
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
if self.product and self.variant_group:
raise ValidationError(
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
)
if not self.product and not self.variant_group:
"""Валидация: должна быть указана группа вариантов ИЛИ (товар [плюс опционально единица продажи])"""
has_variant = bool(self.variant_group)
has_product = bool(self.product)
has_sales_unit = bool(self.sales_unit)
# 1. Проверка на пустоту
if not (has_variant or has_product or has_sales_unit):
raise ValidationError(
"Необходимо указать либо товар, либо группу вариантов."
)
# 2. Несовместимость: Группа вариантов VS Товар/Единица
if has_variant and (has_product or has_sales_unit):
raise ValidationError(
"Нельзя указывать группу вариантов одновременно с товаром или единицей продажи."
)
# 3. Зависимость: Если есть sales_unit, должен быть product
if has_sales_unit and not has_product:
raise ValidationError(
"Если указана единица продажи, должен быть выбран соответствующий товар."
)
# 4. Проверка принадлежности
if has_sales_unit and has_product and self.sales_unit.product != self.product:
raise ValidationError(
"Выбранная единица продажи не принадлежит указанному товару."
)
def get_display_name(self):
"""Возвращает строку для отображения названия компонента"""
if self.variant_group:
# Приоритет: сначала единица продажи, затем товар, затем группа вариантов
if self.sales_unit:
return f"[Единица продажи] {self.sales_unit.name}"
elif self.product:
return self.product.name
elif self.variant_group:
return f"[Варианты] {self.variant_group.name}"
return self.product.name if self.product else "Не указан"
return "Не указан"
def has_priorities_set(self):
"""Проверяет, настроены ли приоритеты замены для данного компонента"""
@@ -435,10 +492,16 @@ class KitItem(models.Model):
"""
Возвращает список доступных товаров для этого компонента.
Если указана единица продажи - возвращает товар, к которому она относится.
Если указан конкретный товар - возвращает его.
Если указаны приоритеты - возвращает товары в порядке приоритета.
Если не указаны приоритеты - возвращает все активные товары из группы вариантов.
"""
# Приоритет: сначала единица продажи, затем товар, затем группа вариантов
if self.sales_unit:
# Если указана единица продажи, возвращаем товар, к которому она относится
return [self.sales_unit.product]
if self.product:
# Если указан конкретный товар, возвращаем только его
return [self.product]

View File

@@ -51,6 +51,8 @@ class Product(BaseProductEntity):
on_delete=models.PROTECT,
related_name='products',
verbose_name="Базовая единица",
null=True,
blank=True,
help_text="Единица хранения и закупки (банч, кг, шт). Товар принимается и хранится в этих единицах."
)
@@ -139,6 +141,14 @@ class Product(BaseProductEntity):
from ..services.cost_calculator import ProductCostCalculator
return ProductCostCalculator.get_cost_details(self)
@property
def kit_items_using_as_sales_unit(self):
"""
Возвращает QuerySet KitItem, где этот товар используется как единица продажи.
"""
from .kits import KitItem
return KitItem.objects.filter(sales_unit__product=self)
def save(self, *args, **kwargs):
# Используем сервис для подготовки к сохранению
ProductSaveService.prepare_product_for_save(self)

View File

@@ -1,9 +1,11 @@
"""
Сервисы для бизнес-логики products приложения.
Следует принципу "Skinny Models, Fat Services".
Следует принципу "Тонкие модели, толстые сервисы".
"""
from .unit_service import UnitOfMeasureService
from .ai.bouquet_names import BouquetNameGenerator
__all__ = [
'UnitOfMeasureService',
'BouquetNameGenerator',
]

View File

@@ -0,0 +1,6 @@
"""
AI-сервисы для products приложения.
Содержит инструменты для взаимодействия с нейросетями для решения специфичных
бизнес-задач, таких как генерация названий продуктов, описаний, классификация и т.д.
"""

View File

@@ -0,0 +1,48 @@
from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict
from integrations.services.ai_services.glm_service import GLMIntegrationService
from integrations.services.ai_services.openrouter_service import OpenRouterIntegrationService
from integrations.models.ai_services.glm import GLMIntegration
from integrations.models.ai_services.openrouter import OpenRouterIntegration
import logging
logger = logging.getLogger(__name__)
class BaseAIProductService(ABC):
"""
Абстрактный базовый класс для AI-сервисов продуктов
"""
@abstractmethod
def generate(self, **kwargs) -> Tuple[bool, str, Optional[Dict]]:
"""
Основной метод генерации
"""
pass
@classmethod
def get_glm_service(cls) -> Optional[GLMIntegrationService]:
"""
Получить сервис GLM из активной интеграции
"""
try:
integration = GLMIntegration.objects.filter(is_active=True).first()
if integration:
return GLMIntegrationService(integration)
except Exception as e:
logger.error(f"Ошибка при получении GLM сервиса: {str(e)}")
return None
@classmethod
def get_openrouter_service(cls) -> Optional[OpenRouterIntegrationService]:
"""
Получить сервис OpenRouter из активной интеграции
"""
try:
integration = OpenRouterIntegration.objects.filter(is_active=True).first()
if integration:
return OpenRouterIntegrationService(integration)
except Exception as e:
logger.error(f"Ошибка при получении OpenRouter сервиса: {str(e)}")
return None

View File

@@ -0,0 +1,272 @@
from typing import Tuple, Optional, Dict, List
from .base import BaseAIProductService
import logging
from django.db import models
logger = logging.getLogger(__name__)
class BouquetNameGenerator(BaseAIProductService):
"""
Сервис для генерации и управления названиями букетов
"""
DEFAULT_SYSTEM_PROMPT = (
"Вы эксперт в создании красивых, привлекательных и продаваемых названий для букетов цветов. "
"Ваша цель — генерировать запоминающиеся и выразительные названия, которые привлекут покупателей. "
"Названия должны быть краткими (2-4 слов), креативными и соответствующими характеристикам букета. "
"Избегайте общих терминов. Фокусируйтесь на эмоциях, эстетике"
)
# Константы
MAX_TOKENS_GENERATION = 3000
DEFAULT_COUNT = 500
MAX_GENERATION_COUNT = 1000
SKIP_PREFIXES = {'here', 'names', "i'm", 'sorry', 'i hope', 'hope'}
def generate(
self,
count: int = 500,
characteristics: Optional[str] = None,
occasion: Optional[str] = None,
language: str = "russian"
) -> Tuple[bool, str, Optional[Dict]]:
"""
Генерация названий букетов
Args:
count: Количество названий для генерации
characteristics: Характеристики букетов (например, "розы, лилии, яркий")
occasion: П'occasion (например, "день рождения, Valentine's Day")
language: Язык генерации
Returns:
Tuple: (success, message, data) где data содержит список названий
"""
# Валидация параметров
if count > self.MAX_GENERATION_COUNT:
count = self.MAX_GENERATION_COUNT
logger.warning(f"Count reduced to {self.MAX_GENERATION_COUNT}")
logger.info(f"Генерация {count} названий для букетов")
# Получаем доступный AI-сервис
service = self.get_glm_service() or self.get_openrouter_service()
if not service:
return False, "Нет активных AI-интеграций", None
# Формируем промпт
prompt = f"Сгенерируй {count} креативных и привлекательных названий для букетов цветов"
if characteristics:
prompt += f" с следующими характеристиками: {characteristics}"
if occasion:
prompt += f" для праздника: {occasion}"
prompt += (
"\n\nТребования к каждому названию:\n"
"- 2, 3 или 4 слова в равных пропорциях\n"
"- Выразительные и эмоциональные\n"
"- Продаваемые и запоминающиеся\n"
"- Избегайте общих названий типа 'Букет #1'\n"
"- Фокусируйтесь на красоте, романтике и подарках\n"
"- Используйте прилагательные и описательные слова\n"
"- Не используйте символы пунктуации в середине названий\n"
"\nВерните названия в виде нумерованного списка, по одному на строку.\n"
"Примеры хороших названий:\n"
"- 2 слова: 'Весенние Розы', 'Летнее Сияние', 'Нежность', 'Романтика'\n"
"- 3 слова: 'Весенний Вальс', 'Нежность Роз', 'Сияние Любви', 'Танец Цветов'\n"
"- 4 слова: 'Шепот Весенней Нежности', 'Сияние Розовой Любви', 'Танец Цветов Весны', 'Шёпот Сердечной Романтики'"
)
# Вызов AI-сервиса
success, msg, response = service.generate_text(
prompt=prompt,
system_prompt=self.DEFAULT_SYSTEM_PROMPT,
max_tokens=3000 # Увеличиваем лимит для большего числа названий
)
if not success:
return False, msg, None
# Парсим результат
names = self._parse_response(response.get('generated_text', ''))
return True, f"Сгенерировано {len(names)} названий для букетов", {
'names': names,
'model': response.get('model'),
'usage': response.get('usage')
}
def _parse_response(self, text: str) -> List[str]:
"""
Парсит текстовый ответ AI и извлекает названия букетов
"""
names = []
lines = text.split('\n')
for line in lines:
line = line.strip()
# Пропускаем пустые строки и заголовки
if not line or any(line.lower().startswith(prefix) for prefix in self.SKIP_PREFIXES):
continue
# Удаляем номера списка
if line and (line[0].isdigit() or line[0] == '-'):
# Удаляем номер и точку или дефис
if '.' in line:
line = line.split('.', 1)[1].strip()
else:
line = line[1:].strip()
# Пропускаем строки, которые стали пустыми после удаления номера
if not line:
continue
# Удаляем markdown форматирование (жирный, курсив)
line = line.replace('**', '').replace('*', '').replace('"', '').replace("'", '').strip()
if line:
# Приводим к нужному формату: первое слово с заглавной, остальные строчные
normalized_line = self._normalize_case(line)
names.append(normalized_line)
# Фильтруем и сортируем названия по длине для равномерного распределения
names_by_length = {2: [], 3: [], 4: []}
for name in names:
word_count = len(name.split())
if word_count in names_by_length:
names_by_length[word_count].append(name)
# Удаляем дубликаты в каждой группе
for length in names_by_length:
unique_list = []
seen = set()
for name in names_by_length[length]:
if name not in seen:
seen.add(name)
unique_list.append(name)
names_by_length[length] = unique_list
# Объединяем названия в один список в пропорциях 2:3:4
balanced_names = []
# Определяем максимальное количество названий одного типа
max_per_length = max(len(names_list) for names_list in names_by_length.values()) if any(names_by_length.values()) else 0
# Добавляем названия по одному из каждой категории по очереди
for i in range(max_per_length):
for length in [2, 3, 4]: # Проходим по длине 2, 3, 4
if i < len(names_by_length[length]):
balanced_names.append(names_by_length[length][i])
return balanced_names
def _normalize_case(self, text: str) -> str:
"""
Приводит текст к формату: первое слово с заглавной буквы, остальные строчные
Например: "романтический БУКЕТ роз" -> "Романтический букет роз"
"""
if not text:
return text
# Разбиваем текст на слова
words = text.split()
if not words:
return text
# Первое слово с заглавной буквы, остальные строчные
normalized_words = [words[0].capitalize()] + [word.lower() for word in words[1:]]
# Собираем обратно в строку
return ' '.join(normalized_words)
def generate_and_store(
self,
count: int = 500,
characteristics: Optional[str] = None,
occasion: Optional[str] = None,
language: str = "russian"
) -> Tuple[bool, str, Optional[Dict]]:
"""
Генерирует названия и сохраняет в базу данных
"""
from products.models import BouquetName
success, msg, data = self.generate(count, characteristics, occasion, language)
if success and data:
# Сохраняем названия в базу
stored_count = 0
failed_count = 0
for name in data['names']:
try:
BouquetName.objects.get_or_create(
name=name,
language=language,
defaults={
'is_approved': False
}
)
stored_count += 1
except Exception as e:
logger.error(f"Ошибка сохранения названия '{name}': {e}")
failed_count += 1
success_msg = f"Сгенерировано и сохранено {stored_count} названий для букетов"
if failed_count > 0:
success_msg += f", не удалось сохранить {failed_count} названий"
return True, success_msg, data
return success, msg, data
def get_approved_names(
self,
color_tags: Optional[List[str]] = None,
occasion_tags: Optional[List[str]] = None,
style_tags: Optional[List[str]] = None,
language: str = "russian",
limit: int = 100
) -> List[str]:
"""
Получает одобренные названия с фильтрацией по тегам
"""
from products.models import BouquetName
queryset = BouquetName.objects.filter(
is_approved=True,
language=language
)
if color_tags:
queryset = queryset.filter(color_tags__name__in=color_tags)
if occasion_tags:
queryset = queryset.filter(occasion_tags__name__in=occasion_tags)
if style_tags:
queryset = queryset.filter(style_tags__name__in=style_tags)
# Сортируем по популярности
queryset = queryset.order_by('-usage_count')
return list(queryset.values_list('name', flat=True)[:limit])
def mark_as_used(self, name: str, language: str = "russian") -> None:
"""
Увеличивает счетчик использования названия
"""
from products.models import BouquetName
BouquetName.objects.filter(
name=name,
language=language
).update(
usage_count=models.F('usage_count') + 1
)

View File

@@ -111,6 +111,10 @@ def make_kit_permanent(kit: ProductKit) -> bool:
kit.is_temporary = False
kit.order = None # Отвязываем от заказа
kit.save()
# Очищаем зафиксированные цены - теперь будет использоваться актуальная цена товаров
kit.kit_items.update(unit_price=None)
return True

View File

@@ -31,6 +31,7 @@ class UnitOfMeasureService:
{'code': 'банч', 'name': 'Банч', 'short_name': 'банч', 'position': 10},
{'code': 'ветка', 'name': 'Ветка', 'short_name': 'вет.', 'position': 11},
{'code': 'пучок', 'name': 'Пучок', 'short_name': 'пуч.', 'position': 12},
{'code': 'коробка', 'name': 'Коробка', 'short_name': 'кор.', 'position': 13},
]
@classmethod

View File

@@ -65,7 +65,7 @@
{% elif item.item_type == 'kit' %}
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
<a href="{% url 'products:kit-detail' item.pk %}"
<a href="{% url 'products:productkit-detail' item.pk %}"
style="color: #6c757d;">{{ item.name }}</a>
{% endif %}
</td>
@@ -100,7 +100,7 @@
{% elif item.item_type == 'product' %}
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% elif item.item_type == 'kit' %}
<a href="{% url 'products:kit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
<a href="{% url 'products:productkit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% endif %}
</td>
</tr>

View File

@@ -8,8 +8,7 @@
<div id="kititem-forms">
{% for kititem_form in kititem_formset %}
<div class="card mb-2 kititem-form border"
data-form-index="{{ forloop.counter0 }}"
<div class="card mb-2 kititem-form border" data-form-index="{{ forloop.counter0 }}"
data-product-id="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.id }}{% endif %}"
data-product-price="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.actual_price|default:0 }}{% else %}0{% endif %}">
{{ kititem_form.id }}
@@ -22,7 +21,7 @@
<div class="row g-2 align-items-end">
<!-- ТОВАР -->
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Товар</label>
{{ kititem_form.product }}
{% if kititem_form.product.errors %}
@@ -30,19 +29,27 @@
{% endif %}
</div>
<!-- ЕДИНИЦА ПРОДАЖИ -->
<div class="col-md-2">
<label class="form-label small text-muted mb-1">Единица продажи</label>
{{ kititem_form.sales_unit }}
{% if kititem_form.sales_unit.errors %}
<div class="text-danger small">{{ kititem_form.sales_unit.errors }}</div>
{% endif %}
</div>
<!-- РАЗДЕЛИТЕЛЬ ИЛИ -->
<div class="col-md-1 d-flex justify-content-center align-items-center">
<div class="kit-item-separator">
<span class="separator-text">ИЛИ</span>
<i class="bi bi-info-circle separator-help"
data-bs-toggle="tooltip"
<i class="bi bi-info-circle separator-help" data-bs-toggle="tooltip"
data-bs-placement="top"
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
</div>
</div>
<!-- ГРУППА ВАРИАНТОВ -->
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Группа вариантов</label>
{{ kititem_form.variant_group }}
{% if kititem_form.variant_group.errors %}
@@ -62,7 +69,9 @@
<!-- УДАЛЕНИЕ -->
<div class="col-md-1 text-end">
{% if kititem_form.DELETE %}
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none'; if(typeof calculateFinalPrice === 'function') calculateFinalPrice();" title="Удалить">
<button type="button" class="btn btn-sm btn-link text-danger p-0"
onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none'; if(typeof calculateFinalPrice === 'function') calculateFinalPrice();"
title="Удалить">
<i class="bi bi-x-lg"></i>
</button>
{{ kititem_form.DELETE }}

View File

@@ -38,7 +38,7 @@
/**
* Инициализирует Select2 для элемента с AJAX поиском товаров
* @param {Element} element - DOM элемент select
* @param {string} type - Тип поиска ('product' или 'variant')
* @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
* @param {string} apiUrl - URL API для поиска
* @returns {boolean} - true если инициализация прошла успешно, false иначе
*/
@@ -70,9 +70,34 @@
var placeholders = {
'product': 'Начните вводить название товара...',
'variant': 'Начните вводить название группы...'
'variant': 'Начните вводить название группы...',
'sales_unit': 'Выберите единицу продажи...'
};
// Для единиц продажи используем другой подход - не AJAX, а загрузка при выборе товара
if (type === 'sales_unit') {
try {
$element.select2({
theme: 'bootstrap-5',
placeholder: placeholders[type] || 'Выберите...',
allowClear: true,
width: '100%',
language: 'ru',
minimumInputLength: 0,
dropdownAutoWidth: false,
// Для единиц продажи не используем AJAX, т.к. они загружаются при выборе товара
disabled: true, // Изначально отключен до выбора товара
templateResult: formatSelectResult,
templateSelection: formatSelectSelection
});
console.log('initProductSelect2: successfully initialized sales_unit for', element.name);
return true;
} catch (error) {
console.error('initProductSelect2: initialization error for sales_unit', error);
return false;
}
} else {
// Для товаров и вариантов используем AJAX
try {
$element.select2({
theme: 'bootstrap-5',
@@ -112,18 +137,25 @@
console.error('initProductSelect2: initialization error', error);
return false;
}
}
};
/**
* Инициализирует Select2 для всех селектов, совпадающих с паттерном
* @param {string} fieldPattern - Паттерн name атрибута (например: 'items-', 'kititem-')
* @param {string} type - Тип поиска ('product' или 'variant')
* @param {string} type - Тип поиска ('product', 'variant' или 'sales_unit')
* @param {string} apiUrl - URL API для поиска
*/
window.initAllProductSelect2 = function(fieldPattern, type, apiUrl) {
if (type === 'sales_unit') {
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-sales_unit"]').forEach(function(element) {
window.initProductSelect2(element, type, apiUrl);
});
} else {
document.querySelectorAll('[name*="' + fieldPattern + '"][name*="-product"]').forEach(function(element) {
window.initProductSelect2(element, type, apiUrl);
});
}
};
})();
</script>

View File

@@ -362,6 +362,113 @@
</table>
</div>
</div>
<!-- Комплекты, содержащие этот товар как единицу продажи -->
{% if kit_items_using_sales_units %}
<div class="card mt-4">
<div class="card-header">
<h5>Комплекты, содержащие этот товар как единицу продажи</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Количество в комплекте</th>
<th>Цена за единицу</th>
</tr>
</thead>
<tbody>
{% for kit_item in kit_items_using_sales_units %}
<tr>
<td>
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
{{ kit_item.kit.name }}
</a>
</td>
<td>{{ kit_item.quantity|default:"1" }}</td>
<td>{{ kit_item.sales_unit.actual_price|default:"0.00" }} руб.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Комплекты, содержащие этот товар напрямую -->
{% if kit_items_using_products %}
<div class="card mt-4">
<div class="card-header">
<h5>Комплекты, содержащие этот товар напрямую</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Количество в комплекте</th>
</tr>
</thead>
<tbody>
{% for kit_item in kit_items_using_products %}
<tr>
<td>
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
{{ kit_item.kit.name }}
</a>
</td>
<td>{{ kit_item.quantity|default:"1" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Комплекты, содержащие этот товар как часть группы вариантов -->
{% if variant_group_kit_items %}
<div class="card mt-4">
<div class="card-header">
<h5>Комплекты, содержащие этот товар как часть группы вариантов</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Группа вариантов</th>
<th>Количество в комплекте</th>
</tr>
</thead>
<tbody>
{% for variant_group_item in variant_group_kit_items %}
{% for kit_item in variant_group_item.variant_group.kit_items.all %}
{% if kit_item.product == product %}
<tr>
<td>
<a href="{% url 'products:productkit-detail' kit_item.kit.pk %}">
{{ kit_item.kit.name }}
</a>
</td>
<td>{{ variant_group_item.variant_group.name }}</td>
<td>{{ kit_item.quantity|default:"1" }}</td>
</tr>
{% endif %}
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-md-4">

File diff suppressed because it is too large Load Diff

View File

@@ -136,7 +136,13 @@
<tr>
<td>{{ forloop.counter }}</td>
<td>
{% if item.product %}
{% if item.sales_unit %}
<a href="{% url 'products:product-detail' item.sales_unit.product.pk %}">
{{ item.sales_unit.name }}
</a>
<br>
<small class="text-muted">Единица продажи: {{ item.sales_unit.product.name }}</small>
{% elif item.product %}
<a href="{% url 'products:product-detail' item.product.pk %}">
{{ item.product.name }}
</a>
@@ -149,7 +155,9 @@
{% endif %}
</td>
<td>
{% if item.product %}
{% if item.sales_unit %}
<span class="badge bg-info">Единица продажи</span>
{% elif item.product %}
<span class="badge bg-success">Товар</span>
{% else %}
<span class="badge bg-primary">Варианты</span>

View File

@@ -8,7 +8,8 @@
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb breadcrumb-sm mb-0">
<li class="breadcrumb-item"><a href="{% url 'products:products-list' %}">Комплекты</a></li>
<li class="breadcrumb-item"><a href="{% url 'products:productkit-detail' object.pk %}">{{ object.name }}</a></li>
<li class="breadcrumb-item"><a href="{% url 'products:productkit-detail' object.pk %}">{{ object.name }}</a>
</li>
<li class="breadcrumb-item active">Редактирование</li>
</ol>
</nav>
@@ -27,7 +28,7 @@
<div class="row g-3">
<!-- ЛЕВАЯ КОЛОНКА: Основная информация -->
<div class="col-lg-8">
<div class="col-lg-9">
<!-- Название -->
<div class="mb-3">
{{ form.name }}
@@ -65,17 +66,273 @@
<!-- ФОТОГРАФИИ -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
<h6 class="mb-2 text-muted"><i class="bi bi-images me-1"></i>Фотографии</h6>
<h6 class="mb-3 text-muted"><i class="bi bi-images me-1"></i>Фотографии комплекта</h6>
<!-- Контейнер для сообщений об операциях с фото -->
<div id="photos-messages-container"></div>
<!-- Существующие фотографии (только при редактировании) -->
{% if object and productkit_photos %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h6 class="mb-0 text-secondary small">
<i class="bi bi-collection"></i> Текущие фотографии
<span class="badge bg-primary rounded-pill" id="photos-count">{{ photos_count }}</span>
</h6>
</div>
<button type="button" id="delete-selected-btn" class="btn btn-danger btn-sm shadow-sm" style="display: none;">
<i class="bi bi-trash"></i> Удалить (<span id="selected-count">0</span>)
</button>
</div>
<div class="row g-2 mb-2" id="photos-grid">
{% for photo in productkit_photos %}
<div class="col-lg-3 col-md-4 col-sm-6 photo-card" data-photo-id="{{ photo.pk }}">
<div class="card h-100 border-0 shadow-sm hover-lift" style="transition: all 0.3s ease;">
<!-- Чекбокс для выбора -->
<div class="position-absolute" style="top: 6px; left: 6px; z-index: 10;">
<div class="form-check">
<input type="checkbox" class="form-check-input photo-checkbox shadow-sm"
data-photo-id="{{ photo.pk }}"
id="photo-check-{{ photo.pk }}"
style="width: 20px; height: 20px; cursor: pointer;">
</div>
</div>
<!-- Бейдж главного фото -->
{% if photo.order == 0 %}
<div class="position-absolute" style="top: 6px; right: 6px; z-index: 10;">
<span class="badge bg-success shadow-sm" style="font-size: 0.7rem;">
<i class="bi bi-star-fill"></i> Главное
</span>
</div>
{% endif %}
<!-- Кликабельное фото -->
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden"
data-bs-toggle="modal"
data-bs-target="#photoModal{{ photo.pk }}"
style="cursor: pointer;">
<img src="{{ photo.get_thumbnail_url }}"
alt="Фото комплекта"
class="object-fit-contain p-1"
style="transition: transform 0.3s ease;"
onmouseover="this.style.transform='scale(1.05)'"
onmouseout="this.style.transform='scale(1)'">
</div>
<div class="card-body p-2 bg-white">
<!-- Кнопка "Сделать главным" -->
{% if photo.order != 0 %}
<a href="{% url 'products:productkit-photo-set-main' photo.pk %}"
class="btn btn-outline-warning btn-sm w-100 mb-1"
style="font-size: 0.75rem; padding: 0.25rem 0.5rem;"
title="Сделать главным">
<i class="bi bi-star"></i> Главное
</a>
{% endif %}
<!-- Кнопки перемещения -->
<div class="btn-group w-100 mb-1" role="group">
<a href="{% url 'products:productkit-photo-move-up' photo.pk %}"
class="btn btn-outline-secondary btn-sm"
style="font-size: 0.75rem; padding: 0.25rem 0.5rem;"
title="Переместить вверх">
<i class="bi bi-arrow-up"></i>
</a>
<a href="{% url 'products:productkit-photo-move-down' photo.pk %}"
class="btn btn-outline-secondary btn-sm"
style="font-size: 0.75rem; padding: 0.25rem 0.5rem;"
title="Переместить вниз">
<i class="bi bi-arrow-down"></i>
</a>
</div>
<!-- Кнопка удаления -->
<a href="{% url 'products:productkit-photo-delete' photo.pk %}"
class="btn btn-outline-danger btn-sm w-100"
style="font-size: 0.75rem; padding: 0.25rem 0.5rem;"
onclick="return confirm('Удалить это фото?');">
<i class="bi bi-trash"></i> Удалить
</a>
</div>
<div class="card-footer bg-light text-center py-1">
<small class="text-muted" style="font-size: 0.7rem;">
<i class="bi bi-hash"></i> Позиция: {{ photo.order|add:1 }}
</small>
</div>
</div>
</div>
<!-- Модальное окно для просмотра фото -->
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-dark text-white">
<h5 class="modal-title">
<i class="bi bi-image"></i> Фото комплекта
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center bg-dark">
<img src="{{ photo.get_large_url }}" class="img-fluid rounded" alt="Фото комплекта" style="max-height: 75vh;">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Закрыть
</button>
<a href="{% url 'products:productkit-photo-delete' photo.pk %}"
class="btn btn-danger"
onclick="return confirm('Удалить это фото?');">
<i class="bi bi-trash"></i> Удалить фото
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- JavaScript для массового удаления фотографий -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const deleteBtn = document.getElementById('delete-selected-btn');
const selectedCount = document.getElementById('selected-count');
const photosCount = document.getElementById('photos-count');
const checkboxes = document.querySelectorAll('.photo-checkbox');
// Обновляем счётчик выбранных и видимость кнопки
function updateUI() {
const checked = document.querySelectorAll('.photo-checkbox:checked').length;
selectedCount.textContent = checked;
deleteBtn.style.display = checked > 0 ? 'block' : 'none';
}
// Обработчик для каждого чекбокса
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', updateUI);
});
// Обработчик кнопки удаления
deleteBtn.addEventListener('click', function() {
const checked = document.querySelectorAll('.photo-checkbox:checked');
if (checked.length === 0) return;
const photoIds = Array.from(checked).map(cb => cb.dataset.photoId);
const count = photoIds.length;
if (!confirm(`Вы уверены, что хотите удалить ${count} фото?`)) return;
// Отключаем кнопку на время операции
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<i class="bi bi-spinner spin"></i> Удаление...';
// Отправляем запрос на сервер
fetch('{% url "products:productkit-photos-delete-bulk" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ photo_ids: photoIds })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Удаляем карточки фотографий из DOM
photoIds.forEach(photoId => {
const card = document.querySelector(`[data-photo-id="${photoId}"]`);
if (card) card.remove();
});
// Обновляем счётчик фотографий
const newCount = parseInt(photosCount.textContent) - count;
photosCount.textContent = newCount;
// Скрываем блок если фотографий больше нет
if (newCount === 0) {
const photosSection = document.querySelector('#photos-grid')?.closest('.mb-3');
if (photosSection) {
photosSection.style.display = 'none';
}
}
// Показываем сообщение об успехе
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show';
alert.innerHTML = `${data.deleted} фото успешно удалено!
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
const messagesContainer = document.getElementById('photos-messages-container');
if (messagesContainer) {
messagesContainer.innerHTML = '';
messagesContainer.appendChild(alert);
}
// Скрываем кнопку
deleteBtn.style.display = 'none';
selectedCount.textContent = '0';
} else {
throw new Error(data.error || 'Неизвестная ошибка');
}
})
.catch(error => {
alert('Ошибка при удалении: ' + error.message);
console.error(error);
})
.finally(() => {
deleteBtn.disabled = false;
deleteBtn.innerHTML = '<i class="bi bi-trash"></i> Удалить (<span id="selected-count">0</span>)';
document.getElementById('selected-count').textContent = '0';
});
});
});
</script>
{% endif %}
<!-- Поле для загрузки новых фотографий -->
<div class="alert alert-info border-0 shadow-sm mb-0 p-2">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-cloud-upload me-2"></i>
<label for="id_photos" class="form-label fw-bold mb-0 small">
{% if object %}
<i class="bi bi-plus-circle"></i> Добавить новые фото
{% else %}
<i class="bi bi-upload"></i> Загрузить фото
{% endif %}
</label>
</div>
<!-- Загрузка по URL -->
<div class="input-group input-group-sm mb-2">
<input type="url" id="photoUrlInput" class="form-control"
placeholder="Вставьте ссылку на изображение (https://...)">
<button type="button" id="addPhotoByUrlBtn" class="btn btn-outline-secondary">
<i class="bi bi-download"></i> Загрузить
</button>
</div>
<input type="file" name="photos" accept="image/*" multiple class="form-control form-control-sm" id="id_photos">
<small class="form-text text-muted d-block mt-1">
<i class="bi bi-info-circle"></i>
{% if object %}
Выберите фото для добавления к комплекту (можно выбрать несколько, до 5 штук всего)
{% else %}
Выберите фото для комплекта (можно выбрать несколько, до 5 штук)
{% endif %}
</small>
<div id="photoPreviewContainer" class="mt-2" style="display: none;">
<div id="photoPreview" class="row g-1"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ПРАВАЯ КОЛОНКА: Настройки -->
<div class="col-lg-4">
<div class="col-lg-3">
<!-- Расчет цены -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body p-3">
@@ -91,8 +348,10 @@
<!-- Базовая цена (отображение) -->
<div class="mb-3 p-2 rounded" style="background: #f8f9fa; border-left: 3px solid #6c757d;">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен компонентов:</span>
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00 руб.</span>
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен
компонентов:</span>
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00
руб.</span>
</div>
</div>
@@ -106,13 +365,15 @@
<div class="row g-2">
<div class="col-6">
<div class="input-group input-group-sm">
<input type="number" id="id_increase_percent" class="form-control" placeholder="%" step="0.01" min="0">
<input type="number" id="id_increase_percent" class="form-control"
placeholder="%" step="0.01" min="0">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-6">
<div class="input-group input-group-sm">
<input type="number" id="id_increase_amount" class="form-control" placeholder="руб" step="0.01" min="0">
<input type="number" id="id_increase_amount" class="form-control"
placeholder="руб" step="0.01" min="0">
<span class="input-group-text">руб</span>
</div>
</div>
@@ -128,13 +389,15 @@
<div class="row g-2">
<div class="col-6">
<div class="input-group input-group-sm">
<input type="number" id="id_decrease_percent" class="form-control" placeholder="%" step="0.01" min="0">
<input type="number" id="id_decrease_percent" class="form-control"
placeholder="%" step="0.01" min="0">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-6">
<div class="input-group input-group-sm">
<input type="number" id="id_decrease_amount" class="form-control" placeholder="руб" step="0.01" min="0">
<input type="number" id="id_decrease_amount" class="form-control"
placeholder="руб" step="0.01" min="0">
<span class="input-group-text">руб</span>
</div>
</div>
@@ -147,14 +410,18 @@
<!-- Итоговая цена -->
<div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;">
<div class="d-flex justify-content-between align-items-center">
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая цена:</strong></span>
<span id="finalPriceDisplay" class="fw-bold" style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая
цена:</strong></span>
<span id="finalPriceDisplay" class="fw-bold"
style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
</div>
</div>
<!-- Скрытые поля для формы (автоматически заполняются JavaScript) -->
<input type="hidden" id="id_price_adjustment_type" name="price_adjustment_type" value="{{ object.price_adjustment_type|default:'none' }}">
<input type="hidden" id="id_price_adjustment_value" name="price_adjustment_value" value="{{ object.price_adjustment_value|default:0 }}">
<input type="hidden" id="id_price_adjustment_type" name="price_adjustment_type"
value="{{ object.price_adjustment_type|default:'none' }}">
<input type="hidden" id="id_price_adjustment_value" name="price_adjustment_value"
value="{{ object.price_adjustment_value|default:0 }}">
</div>
</div>
@@ -164,7 +431,8 @@
<h6 class="mb-2 text-muted"><i class="bi bi-tag-discount"></i> Цена со скидкой</h6>
<label class="form-label small mb-1">{{ form.sale_price.label }}</label>
{{ form.sale_price }}
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной цены</small>
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной
цены</small>
{% if form.sale_price.errors %}
<div class="text-danger small mt-1">{{ form.sale_price.errors }}</div>
{% endif %}
@@ -233,10 +501,14 @@
</div>
<!-- Sticky Footer -->
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
<div
class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
<a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary">
Отмена
</a>
<a href="{% url 'products:productkit-create' %}?copy_from={{ object.pk }}" class="btn btn-warning text-white mx-2">
<i class="bi bi-files me-1"></i>Копировать комплект
</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>Сохранить изменения
</button>
@@ -350,6 +622,30 @@
object-fit: cover;
}
/* Hover эффект для карточек фото */
.hover-lift:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.photo-card .card {
border: 1px solid #e0e0e0;
}
.photo-card:hover .card {
border-color: #667eea;
}
/* Спиннер для загрузки */
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Alert компактный */
.alert-sm {
padding: 0.5rem 0.75rem;
@@ -362,6 +658,7 @@
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -421,7 +718,9 @@
/* Адаптивность */
@media (max-width: 991px) {
.col-lg-8, .col-lg-4 {
.col-lg-8,
.col-lg-4 {
max-width: 100%;
}
}
@@ -530,11 +829,25 @@ document.addEventListener('DOMContentLoaded', function() {
const form = $(this).closest('.kititem-form');
if (this.value) {
form.attr('data-product-id', this.value);
// Обновляем список единиц продажи
const salesUnitSelect = form.find('[name$="-sales_unit"]').get(0);
if (salesUnitSelect) {
await updateSalesUnitsOptions(salesUnitSelect, this.value);
}
// Загружаем цену и пересчитываем
await getProductPrice(this);
calculateFinalPrice();
}
}).on('select2:unselect', function () {
// Очищаем список единиц продажи
const form = $(this).closest('.kititem-form');
const salesUnitSelect = form.find('[name$="-sales_unit"]').get(0);
if (salesUnitSelect) {
salesUnitSelect.innerHTML = '<option value="">---------</option>';
salesUnitSelect.disabled = true;
}
calculateFinalPrice();
});
@@ -599,6 +912,158 @@ document.addEventListener('DOMContentLoaded', function() {
return 0;
}
// Функция для получения цены единицы продажи
async function getSalesUnitPrice(selectElement) {
if (!selectElement) {
console.warn('getSalesUnitPrice: selectElement is null or undefined');
return 0;
}
const rawValue = selectElement.value;
if (!rawValue) {
return 0;
}
// Извлекаем числовой ID из значения
let salesUnitId;
if (typeof rawValue === 'string' && rawValue.includes('_')) {
salesUnitId = parseInt(rawValue.split('_')[1]);
} else {
salesUnitId = parseInt(rawValue);
}
if (isNaN(salesUnitId) || salesUnitId <= 0) {
console.warn('getSalesUnitPrice: invalid sales unit id', rawValue);
return 0;
}
// Если уже загружена в кэш - возвращаем
const cacheKey = `sales_unit_${salesUnitId}`;
if (priceCache[cacheKey] !== undefined) {
const cachedPrice = parseFloat(priceCache[cacheKey]) || 0;
return cachedPrice;
}
// Пытаемся получить из option element (для стандартного select)
const selectedOption = selectElement.selectedOptions ? selectElement.selectedOptions[0] : null;
if (selectedOption) {
let priceData = selectedOption.dataset.actual_price || selectedOption.dataset.price;
if (priceData) {
const price = parseFloat(priceData) || 0;
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getSalesUnitPrice: from standard select option data', salesUnitId, price);
return price;
}
}
}
// Пытаемся получить из Select2 data
const $select = $(selectElement);
if ($select.data('select2')) { // Check if Select2 is initialized on the element
const selectedData = $select.select2('data');
if (selectedData && selectedData.length > 0) {
const itemData = selectedData[0];
const priceData = itemData.actual_price || itemData.price;
if (priceData) {
const price = parseFloat(priceData) || 0;
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getSalesUnitPrice: from select2 data', salesUnitId, price);
return price;
}
}
}
}
// Загружаем информацию о единице продажи через API
try {
console.log('getSalesUnitPrice: fetching from API', salesUnitId);
const response = await fetch(
`{% url "products:api-product-sales-units" product_id=0 %}`.replace('/0/', `/${salesUnitId}/`),
{ method: 'GET', headers: { 'Accept': 'application/json' } }
);
if (response.ok) {
const data = await response.json();
if (data.sales_units && data.sales_units.length > 0) {
const salesUnitData = data.sales_units.find(su => su.id == salesUnitId);
if (salesUnitData) {
const price = parseFloat(salesUnitData.actual_price || salesUnitData.price || 0);
if (price > 0) {
priceCache[cacheKey] = price;
console.log('getSalesUnitPrice: from API', salesUnitId, price);
}
return price;
}
}
}
} catch (error) {
console.error('Error fetching sales unit price:', error);
}
console.warn('getSalesUnitPrice: returning 0 for sales unit', salesUnitId);
return 0;
}
// Функция для обновления списка единиц продажи при выборе товара
async function updateSalesUnitsOptions(salesUnitSelect, productValue) {
// Сохраняем текущее значение перед очисткой (важно для редактирования)
const currentValue = salesUnitSelect.value;
// Очищаем текущие опции
salesUnitSelect.innerHTML = '<option value="">---------</option>';
salesUnitSelect.disabled = true;
if (!productValue) {
return;
}
// Извлекаем ID товара
let productId;
if (productValue.includes('_')) {
const parts = productValue.split('_');
productId = parseInt(parts[1]);
} else {
productId = parseInt(productValue);
}
if (isNaN(productId) || productId <= 0) {
console.warn('updateSalesUnitsOptions: invalid product id', productValue);
return;
}
try {
// Загружаем единицы продажи для выбранного товара
const response = await fetch(
`{% url "products:api-product-sales-units" product_id=0 %}`.replace('/0/', `/${productId}/`),
{ method: 'GET', headers: { 'Accept': 'application/json' } }
);
if (response.ok) {
const data = await response.json();
if (data.sales_units && data.sales_units.length > 0) {
data.sales_units.forEach(su => {
const option = document.createElement('option');
option.value = su.id;
option.textContent = `${su.name} (${su.actual_price || su.price} руб.)`;
option.dataset.price = su.actual_price || su.price;
option.dataset.actual_price = su.actual_price;
salesUnitSelect.appendChild(option);
});
salesUnitSelect.disabled = false;
// Восстанавливаем значение
if (currentValue) {
salesUnitSelect.value = currentValue;
}
// Обновляем Select2
$(salesUnitSelect).trigger('change');
}
}
} catch (error) {
console.error('Error fetching sales units:', error);
}
}
// Функция для расчета финальной цены
async function calculateFinalPrice() {
// Получаем базовую цену (сумма всех компонентов)
@@ -612,20 +1077,28 @@ document.addEventListener('DOMContentLoaded', function() {
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const quantity = parseFloat(form.querySelector('[name$="-quantity"]')?.value || '1');
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
const salesUnitSelect = form.querySelector('[name$="-sales_unit"]');
if (deleteCheckbox && deleteCheckbox.checked) continue;
// Пропускаем если количество не валидно
const validQuantity = quantity > 0 ? quantity : 1;
// Проверяем товар
if (productSelect && productSelect.value) {
// Проверяем единицу продажи (имеет наивысший приоритет)
if (salesUnitSelect && salesUnitSelect.value) {
const salesUnitPrice = await getSalesUnitPrice(salesUnitSelect);
if (salesUnitPrice > 0) {
newBasePrice += (salesUnitPrice * validQuantity);
}
}
// Проверяем товар (если нет единицы продажи)
else if (productSelect && productSelect.value) {
const productPrice = await getProductPrice(productSelect);
if (productPrice > 0) {
newBasePrice += (productPrice * validQuantity);
}
}
// Проверяем группу вариантов
// Проверяем группу вариантов (если нет ни единицы продажи, ни товара)
else if (variantGroupSelect && variantGroupSelect.value) {
const variantPrice = await getVariantGroupPrice(variantGroupSelect);
if (variantPrice > 0) {
@@ -795,6 +1268,7 @@ document.addEventListener('DOMContentLoaded', function() {
const selectedProducts = {{ selected_products|default:"{}"|safe }};
const selectedVariants = {{ selected_variants|default:"{}"|safe }};
const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }};
$('[name$="-product"]').each(function () {
const fieldName = $(this).attr('name');
@@ -811,34 +1285,72 @@ document.addEventListener('DOMContentLoaded', function() {
$(this).on('select2:select select2:unselect', calculateFinalPrice);
});
$('[name$="-sales_unit"]').each(function () {
const fieldName = $(this).attr('name');
const preloadedData = selectedSalesUnits[fieldName] || null;
initSelect2(this, 'sales_unit', preloadedData);
$(this).on('select2:select select2:unselect', calculateFinalPrice);
});
// ========== УПРАВЛЕНИЕ КОМПОНЕНТАМИ ==========
function updateFieldStatus(form) {
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const salesUnitSelect = form.querySelector('[name$="-sales_unit"]');
if (!productSelect || !variantGroupSelect) return;
if (!productSelect || !variantGroupSelect || !salesUnitSelect) return;
const hasProduct = productSelect.value;
const hasVariant = variantGroupSelect.value;
const hasSalesUnit = salesUnitSelect.value;
variantGroupSelect.disabled = !!hasProduct;
productSelect.disabled = !!hasVariant;
// Если выбрана группа вариантов, блокируем товар и единицу продажи
if (hasVariant) {
productSelect.disabled = true;
salesUnitSelect.disabled = true;
}
// Если выбран товар, разблокируем единицу продажи и блокируем группу вариантов
else if (hasProduct) {
salesUnitSelect.disabled = false;
variantGroupSelect.disabled = true;
}
// Если выбрана только единица продажи, но не товар - блокируем все остальные
else if (hasSalesUnit) {
productSelect.disabled = true;
variantGroupSelect.disabled = true;
}
// Если ничего не выбрано - разблокируем товар и группу вариантов, блокируем единицу продажи
else {
productSelect.disabled = false;
variantGroupSelect.disabled = false;
salesUnitSelect.disabled = true;
}
}
function initializeForm(form) {
updateFieldStatus(form);
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const salesUnitSelect = form.querySelector('[name$="-sales_unit"]');
[productSelect, variantGroupSelect].forEach(field => {
[productSelect, variantGroupSelect, salesUnitSelect].forEach(field => {
if (field) {
field.addEventListener('change', () => {
updateFieldStatus(form);
// Обновляем список единиц продажи при изменении товара
if (field === productSelect) {
updateSalesUnitsOptions(salesUnitSelect, productSelect.value);
}
calculateFinalPrice();
});
}
});
// Инициализируем список единиц продажи, если товар уже выбран
if (productSelect && productSelect.value) {
updateSalesUnitsOptions(salesUnitSelect, productSelect.value);
}
const quantityInput = form.querySelector('[name$="-quantity"]');
if (quantityInput) {
quantityInput.addEventListener('change', calculateFinalPrice);
@@ -862,18 +1374,28 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="card mb-2 kititem-form border new-item">
<div class="card-body p-2">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Товар</label>
<select class="form-control form-control-sm" name="kititem-${newFormId}-product">
<option value="">---------</option>
</select>
</div>
<div class="col-md-1 d-flex justify-content-center">
<div class="col-md-2">
<label class="form-label small text-muted mb-1">Единица продажи</label>
<select class="form-control form-control-sm" name="kititem-${newFormId}-sales_unit">
<option value="">---------</option>
</select>
</div>
<div class="col-md-1 d-flex justify-content-center align-items-center">
<div class="kit-item-separator">
<span class="separator-text">ИЛИ</span>
<i class="bi bi-info-circle separator-help"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Вы можете выбрать что-то одно: либо товар, либо группу вариантов"></i>
</div>
</div>
<div class="col-md-4">
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Группа вариантов</label>
<select class="form-control form-control-sm" name="kititem-${newFormId}-variant_group">
<option value="">---------</option>
@@ -904,13 +1426,36 @@ document.addEventListener('DOMContentLoaded', function() {
const productSelect = newForm.querySelector('[name$="-product"]');
const variantSelect = newForm.querySelector('[name$="-variant_group"]');
const salesUnitSelect = newForm.querySelector('[name$="-sales_unit"]');
initSelect2(productSelect, 'product');
initSelect2(variantSelect, 'variant');
initSelect2(salesUnitSelect, 'sales_unit');
// Добавляем обработчики для новой формы
$(productSelect).on('select2:select select2:unselect', calculateFinalPrice);
$(productSelect).on('select2:select', async function () {
const form = $(this).closest('.kititem-form');
if (this.value) {
form.attr('data-product-id', this.value);
// Обновляем список единиц продажи
if (salesUnitSelect) {
await updateSalesUnitsOptions(salesUnitSelect, this.value);
}
// Загружаем цену и пересчитываем
await getProductPrice(this);
calculateFinalPrice();
}
}).on('select2:unselect', function () {
// Очищаем список единиц продажи
if (salesUnitSelect) {
salesUnitSelect.innerHTML = '<option value="">---------</option>';
salesUnitSelect.disabled = true;
}
calculateFinalPrice();
});
$(variantSelect).on('select2:select select2:unselect', calculateFinalPrice);
$(salesUnitSelect).on('select2:select select2:unselect', calculateFinalPrice);
initializeForm(newForm);
@@ -961,7 +1506,7 @@ document.addEventListener('DOMContentLoaded', function() {
photoPreview.innerHTML = '';
}
});
}
};
window.removePhoto = function (index) {
selectedFiles.splice(index, 1);
@@ -971,6 +1516,85 @@ document.addEventListener('DOMContentLoaded', function() {
photoInput.dispatchEvent(new Event('change'));
};
// ========== ЗАГРУЗКА ФОТО ПО URL ==========
const photoUrlInput = document.getElementById('photoUrlInput');
const addPhotoByUrlBtn = document.getElementById('addPhotoByUrlBtn');
async function addPhotoFromUrl(imageUrl) {
// Валидация URL
try {
new URL(imageUrl);
} catch {
alert('Пожалуйста, введите корректный URL (начинающийся с https://)');
return;
}
if (!imageUrl.match(/\.(jpg|jpeg|png|gif|webp|heic|heif|svg)$/i) && !imageUrl.includes('/')) {
alert('URL должен указывать на изображение (jpg, png, webp, etc.)');
return;
}
try {
// Показываем индикатор загрузки
addPhotoByUrlBtn.disabled = true;
addPhotoByUrlBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
// Скачиваем изображение
const response = await fetch(imageUrl, { mode: 'cors' });
if (!response.ok) throw new Error('Не удалось скачать изображение');
const blob = await response.blob();
// Проверяем, что это изображение
if (!blob.type.startsWith('image/')) {
throw new Error('Файл не является изображением');
}
// Создаем File объект
const filename = imageUrl.split('/').pop().split('?')[0] || 'image.jpg';
const file = new File([blob], filename, { type: blob.type });
// Добавляем в selectedFiles и обновляем input
selectedFiles.push(file);
const dataTransfer = new DataTransfer();
selectedFiles.forEach(f => dataTransfer.items.add(f));
photoInput.files = dataTransfer.files;
// Триггерим событие change для обновления превью
photoInput.dispatchEvent(new Event('change'));
// Очищаем поле ввода
photoUrlInput.value = '';
} catch (error) {
console.error('Error loading image from URL:', error);
alert('Ошибка загрузки изображения: ' + error.message + '\n\nУбедитесь, что сервер изображений поддерживает CORS.');
} finally {
addPhotoByUrlBtn.disabled = false;
addPhotoByUrlBtn.innerHTML = '<i class="bi bi-download"></i> Загрузить';
}
}
if (addPhotoByUrlBtn) {
addPhotoByUrlBtn.addEventListener('click', () => {
const url = photoUrlInput.value.trim();
if (url) {
addPhotoFromUrl(url);
}
});
// Загрузка по Enter
photoUrlInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const url = photoUrlInput.value.trim();
if (url) {
addPhotoFromUrl(url);
}
}
});
}
// ========== ЗАГРУЗКА СОХРАНЁННЫХ ЗНАЧЕНИЙ КОРРЕКТИРОВКИ ==========
setTimeout(async () => {
const currentAdjustmentType = adjustmentTypeInput.value;
@@ -1032,9 +1656,20 @@ document.addEventListener('DOMContentLoaded', function() {
allForms.forEach(form => {
const productSelect = form.querySelector('[name$="-product"]');
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
const salesUnitSelect = form.querySelector('[name$="-sales_unit"]');
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
if (!productSelect.value && !variantGroupSelect.value && deleteCheckbox) {
// Проверяем, что выбран хотя бы один компонент (товар, группа вариантов или единица продажи)
// Если выбрана единица продажи, товар должен быть выбран
if (salesUnitSelect && salesUnitSelect.value && (!productSelect || !productSelect.value)) {
// Если выбрана единица продажи, но не выбран товар - это ошибка
alert('Если выбрана единица продажи, должен быть выбран соответствующий товар.');
e.preventDefault();
return;
}
// Если ничего не выбрано, отмечаем для удаления
if (!productSelect.value && !variantGroupSelect.value && !salesUnitSelect.value && deleteCheckbox) {
deleteCheckbox.checked = true;
}
});

View File

@@ -26,8 +26,13 @@
{% csrf_token %}
<div class="mb-3">
<label for="id_name" class="form-label">Название *</label>
{{ form.name }}
<label for="{{ form.name.id_for_label }}" class="form-label">Название *</label>
<input type="text"
name="{{ form.name.html_name }}"
class="form-control{% if form.name.errors %} is-invalid{% endif %}"
id="{{ form.name.id_for_label }}"
value="{{ form.name.value|default:'' }}"
required>
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{{ form.name.errors }}
@@ -36,8 +41,11 @@
</div>
<div class="mb-3">
<label for="id_description" class="form-label">Описание</label>
{{ form.description }}
<label for="{{ form.description.id_for_label }}" class="form-label">Описание</label>
<textarea name="{{ form.description.html_name }}"
class="form-control{% if form.description.errors %} is-invalid{% endif %}"
id="{{ form.description.id_for_label }}"
rows="3">{{ form.description.value|default:'' }}</textarea>
{% if form.description.errors %}
<div class="invalid-feedback d-block">
{{ form.description.errors }}
@@ -46,8 +54,18 @@
</div>
<div class="mb-3">
<label for="id_categories" class="form-label">Категории</label>
{{ form.categories }}
<label for="{{ form.categories.id_for_label }}" class="form-label">Категории</label>
<select name="{{ form.categories.html_name }}"
class="form-select{% if form.categories.errors %} is-invalid{% endif %}"
id="{{ form.categories.id_for_label }}"
multiple>
{% for value, label in form.categories.field.choices %}
<option value="{{ value }}"
{% if value in form.categories.value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.categories.errors %}
<div class="invalid-feedback d-block">
{{ form.categories.errors }}
@@ -59,8 +77,18 @@
</div>
<div class="mb-3">
<label for="id_tags" class="form-label">Теги</label>
{{ form.tags }}
<label for="{{ form.tags.id_for_label }}" class="form-label">Теги</label>
<select name="{{ form.tags.html_name }}"
class="form-select{% if form.tags.errors %} is-invalid{% endif %}"
id="{{ form.tags.id_for_label }}"
multiple>
{% for value, label in form.tags.field.choices %}
<option value="{{ value }}"
{% if value in form.tags.value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% if form.tags.errors %}
<div class="invalid-feedback d-block">
{{ form.tags.errors }}
@@ -69,8 +97,14 @@
</div>
<div class="mb-3">
<label for="id_sale_price" class="form-label">Цена со скидкой</label>
{{ form.sale_price }}
<label for="{{ form.sale_price.id_for_label }}" class="form-label">Цена со скидкой</label>
<input type="number"
name="{{ form.sale_price.html_name }}"
class="form-control{% if form.sale_price.errors %} is-invalid{% endif %}"
id="{{ form.sale_price.id_for_label }}"
value="{{ form.sale_price.value|default:'' }}"
step="0.01"
min="0">
{% if form.sale_price.errors %}
<div class="invalid-feedback d-block">
{{ form.sale_price.errors }}

View File

@@ -7,7 +7,7 @@
{% csrf_token %}
<div class="container-fluid mt-4">
<h2 class="mb-4">
<i class="bi bi-box-seam"></i> Товары и они комплекты
<i class="bi bi-box-seam"></i> Товары и комплекты
</h2>
<!-- Панель фильтрации и действий -->

View File

@@ -97,7 +97,7 @@
<a href="{% url 'products:unit-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
<button type="submit" class="btn btn-primary">
<button type="submit" name="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ submit_text }}
</button>
</div>

View File

@@ -0,0 +1,290 @@
from django_tenants.test.cases import TenantTestCase
from products.services import BouquetNameGenerator
from products.models import BouquetName, ProductTag
from unittest.mock import patch, MagicMock
class BouquetNameGeneratorTestCase(TenantTestCase):
"""
Тесты для сервиса генерации названий букетов
"""
def setUp(self):
"""
Создаем экземпляр сервиса для тестирования
"""
self.generator = BouquetNameGenerator()
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
def test_generate_with_mock_glm(self, mock_get_glm_service):
"""
Тест генерации названий с мок-объектом GLM сервиса
"""
# Создаем мок-объект сервиса
mock_service = MagicMock()
mock_service.generate_text.return_value = (
True,
"Текст успешно сгенерирован",
{
'generated_text': (
"1. Розавая мечта\n"
"2. Лиловые настроения\n"
"3. Яркий букет для дня рождения\n"
"4. Сладкий сюрприз\n"
"5. Романтическое вдохновение"
),
'model': 'glm-4',
'usage': {'prompt_tokens': 100, 'completion_tokens': 50}
}
)
mock_get_glm_service.return_value = mock_service
# Вызываем метод генерации
success, msg, data = self.generator.generate(count=5)
# Проверки
self.assertTrue(success)
self.assertIn("Сгенерировано 5 названий для букетов", msg)
self.assertIsNotNone(data)
self.assertIn('names', data)
self.assertEqual(len(data['names']), 5)
self.assertEqual(data['model'], 'glm-4')
self.assertIn('usage', data)
# Проверяем, что названия содержат нужные слова
expected_names = [
"Розавая мечта",
"Лиловые настроения",
"Яркий букет для дня рождения",
"Сладкий сюрприз",
"Романтическое вдохновение"
]
self.assertEqual(data['names'], expected_names)
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_openrouter_service')
def test_no_active_integration(self, mock_get_openrouter, mock_get_glm):
"""
Тест случая, когда нет активных интеграций
"""
mock_get_glm.return_value = None
mock_get_openrouter.return_value = None
success, msg, data = self.generator.generate(count=10)
self.assertFalse(success)
self.assertEqual(msg, "Нет активных AI-интеграций")
self.assertIsNone(data)
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
def test_generate_with_characteristics(self, mock_get_glm_service):
"""
Тест генерации с характеристиками
"""
# Создаем мок-объект сервиса
mock_service = MagicMock()
mock_service.generate_text.return_value = (
True,
"Текст успешно сгенерирован",
{
'generated_text': (
"1. Ромашковое небо\n"
"2. Лавандовый спокойствие\n"
"3. Свежие ароматы\n"
"4. Милая композиция\n"
"5. Нежный букет"
),
'model': 'glm-4',
'usage': {'prompt_tokens': 120, 'completion_tokens': 45}
}
)
mock_get_glm_service.return_value = mock_service
success, msg, data = self.generator.generate(
count=5,
characteristics="ромашки, лаванда, свежие",
occasion="день матери"
)
self.assertTrue(success)
self.assertIn("Сгенерировано 5 названий для букетов", msg)
self.assertEqual(len(data['names']), 5)
# Проверяем, что сервис был вызван с нужными параметрами
mock_service.generate_text.assert_called_once()
def test_parse_response_with_markdown(self):
"""
Тест парсинга ответа с Markdown форматированием
"""
response_text = """
Here are 3 beautiful bouquet names for you:
1. **Spring Blossom Delight**
2. *Romantic Rose Elegance*
3. "Sunny Daisy Joy"
I hope you love these!
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 3)
self.assertEqual(names[0], "Spring Blossom Delight")
self.assertEqual(names[1], "Romantic Rose Elegance")
self.assertEqual(names[2], "Sunny Daisy Joy")
def test_parse_response_with_duplicates(self):
"""
Тест парсинга ответа с дубликатами
"""
response_text = """
1. Розавая мечта
2. Лиловые настроения
3. Розавая мечта
4. Сладкий сюрприз
5. Лиловые настроения
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 3)
self.assertIn("Розавая мечта", names)
self.assertIn("Лиловые настроения", names)
self.assertIn("Сладкий сюрприз", names)
def test_parse_response_empty(self):
"""
Тест парсинга пустого ответа
"""
response_text = """
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 0)
def test_parse_response_no_names(self):
"""
Тест парсинга ответа без названий
"""
response_text = """
I'm sorry, but I can't help with that right now.
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 0)
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
def test_generate_and_store(self, mock_get_glm_service):
"""
Тест генерации и сохранения названий в базе данных
"""
# Создаем мок-объект сервиса
mock_service = MagicMock()
mock_service.generate_text.return_value = (
True,
"Текст успешно сгенерирован",
{
'generated_text': (
"1. Розавая мечта\n"
"2. Лиловые настроения\n"
"3. Яркий букет для дня рождения"
),
'model': 'glm-4',
'usage': {'prompt_tokens': 100, 'completion_tokens': 50}
}
)
mock_get_glm_service.return_value = mock_service
# Очищаем базу перед тестом
BouquetName.objects.all().delete()
# Вызываем метод генерации и сохранения
success, msg, data = self.generator.generate_and_store(count=3)
self.assertTrue(success)
self.assertIn("Сгенерировано и сохранено 3 названий для букетов", msg)
self.assertEqual(BouquetName.objects.count(), 3)
def test_mark_as_used(self):
"""
Тест увеличения счетчика использования названия
"""
# Создаем тестовое название
bouquet_name = BouquetName.objects.create(
name="Тестовый букет",
language="russian",
is_approved=True
)
# Проверяем начальное значение счетчика
self.assertEqual(bouquet_name.usage_count, 0)
# Увеличиваем счетчик
self.generator.mark_as_used("Тестовый букет", "russian")
# Проверяем обновленное значение
bouquet_name.refresh_from_db()
self.assertEqual(bouquet_name.usage_count, 1)
def test_get_approved_names(self):
"""
Тест получения одобренных названий
"""
# Очищаем базу перед тестом
BouquetName.objects.all().delete()
# Создаем тестовые данные
BouquetName.objects.create(
name="Одобренный букет 1",
language="russian",
is_approved=True
)
BouquetName.objects.create(
name="Одобренный букет 2",
language="russian",
is_approved=True
)
BouquetName.objects.create(
name="Неодобренный букет",
language="russian",
is_approved=False
)
# Получаем одобренные названия
approved_names = self.generator.get_approved_names(language="russian")
self.assertEqual(len(approved_names), 2)
self.assertIn("Одобренный букет 1", approved_names)
self.assertIn("Одобренный букет 2", approved_names)
self.assertNotIn("Неодобренный букет", approved_names)
def test_bouquet_name_model(self):
"""
Тест создания и работы с моделью BouquetName
"""
# Создаем тестовые теги
red_tag = ProductTag.objects.create(name="красный", slug="krasny")
romantic_tag = ProductTag.objects.create(name="романтический", slug="romanticheskiy")
# Создаем экземпляр модели
bouquet_name = BouquetName.objects.create(
name="Романтический букет",
language="russian",
is_approved=True
)
# Добавляем теги
bouquet_name.color_tags.add(red_tag)
bouquet_name.style_tags.add(romantic_tag)
# Проверяем сохраненные значения
self.assertEqual(bouquet_name.name, "Романтический букет")
self.assertEqual(bouquet_name.language, "russian")
self.assertTrue(bouquet_name.is_approved)
self.assertEqual(bouquet_name.usage_count, 0)
self.assertIn(red_tag, bouquet_name.color_tags.all())
self.assertIn(romantic_tag, bouquet_name.style_tags.all())
# Обновляем поле
bouquet_name.usage_count = 5
bouquet_name.save()
# Проверяем обновление
updated_name = BouquetName.objects.get(id=bouquet_name.id)
self.assertEqual(updated_name.usage_count, 5)

View File

@@ -42,6 +42,7 @@ urlpatterns = [
path('kit/photo/<int:pk>/set-main/', views.productkit_photo_set_main, name='productkit-photo-set-main'),
path('kit/photo/<int:pk>/move-up/', views.productkit_photo_move_up, name='productkit-photo-move-up'),
path('kit/photo/<int:pk>/move-down/', views.productkit_photo_move_down, name='productkit-photo-move-down'),
path('kit/photos/delete-bulk/', views.productkit_photos_delete_bulk, name='productkit-photos-delete-bulk'),
# API endpoints
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
@@ -55,6 +56,10 @@ urlpatterns = [
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'),
path('api/filtered-items-ids/', api_views.get_filtered_items_ids, name='api-filtered-items-ids'),
path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'),
path('api/bouquet-names/random/', api_views.RandomBouquetNamesView.as_view(), name='api-random-bouquet-names'),
path('api/bouquet-names/generate/', api_views.GenerateBouquetNamesView.as_view(), name='api-generate-bouquet-names'),
path('api/bouquet-names/<int:pk>/delete/', api_views.DeleteBouquetNameView.as_view(), name='api-delete-bouquet-name'),
path('api/bouquet-names/count/', api_views.GetBouquetNamesCountView.as_view(), name='api-get-bouquet-names-count'),
# Photo processing status API (for AJAX polling)
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),

View File

@@ -158,49 +158,41 @@ class ImageProcessor:
@staticmethod
def _resize_image(img, size):
"""
Изменяет размер изображения с сохранением пропорций.
Изменяет размер изображения с center-crop до точного квадратного размера.
НЕ увеличивает маленькие изображения (сохраняет качество).
Создает адаптивный квадрат по размеру реального изображения.
Создает квадратное изображение без белых полей.
Args:
img: PIL Image object
size: Кортеж (width, height) - максимальный целевой размер
size: Кортеж (width, height) - целевой размер (обычно квадратный)
Returns:
PIL Image object - квадратное изображение с минимальным белым фоном
PIL Image object - квадратное изображение без белых полей
"""
# Копируем изображение, чтобы не модифицировать оригинал
img_copy = img.copy()
target_width, target_height = size
# Вычисляем пропорции исходного изображения и целевого размера
img_aspect = img_copy.width / img_copy.height
target_aspect = size[0] / size[1]
# Шаг 1: Center crop для получения квадрата
# Определяем минимальную сторону (будет размер квадрата)
min_side = min(img_copy.width, img_copy.height)
# Определяем, какой размер будет ограничивающим при масштабировании
if img_aspect > target_aspect:
# Изображение шире - ограничиваемый размер это ширина
new_width = min(img_copy.width, size[0])
new_height = int(new_width / img_aspect)
# Вычисляем координаты для обрезки из центра
left = (img_copy.width - min_side) // 2
top = (img_copy.height - min_side) // 2
right = left + min_side
bottom = top + min_side
# Обрезаем до квадрата
img_cropped = img_copy.crop((left, top, right, bottom))
# Шаг 2: Масштабируем до целевого размера (если исходный квадрат больше цели)
# Не увеличиваем маленькие изображения
if min_side > target_width:
img_resized = img_cropped.resize((target_width, target_height), Image.Resampling.LANCZOS)
else:
# Изображение выше - ограничиваемый размер это высота
new_height = min(img_copy.height, size[1])
new_width = int(new_height * img_aspect)
img_resized = img_cropped
# Масштабируем только если необходимо (не увеличиваем маленькие изображения)
if img_copy.width > new_width or img_copy.height > new_height:
img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Создаем адаптивный квадрат по размеру реального изображения (а не по конфигурации)
# Это позволяет избежать огромных белых полей для маленьких фото
square_size = max(img_copy.width, img_copy.height)
new_img = Image.new('RGB', (square_size, square_size), (255, 255, 255))
# Центрируем исходное изображение на белом фоне
offset_x = (square_size - img_copy.width) // 2
offset_y = (square_size - img_copy.height) // 2
new_img.paste(img_copy, (offset_x, offset_y))
return new_img
return img_resized
@staticmethod
def _make_square_image(img, max_size):

View File

@@ -21,6 +21,7 @@ from .photo_management import (
productkit_photo_set_main,
productkit_photo_move_up,
productkit_photo_move_down,
productkit_photos_delete_bulk,
)
# Управление фотографиями (Category)
@@ -114,7 +115,14 @@ from .attribute_views import (
)
# API представления
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
from .api_views import (
search_products_and_variants,
validate_kit_cost,
create_temporary_kit_api,
create_tag_api,
RandomBouquetNamesView,
GenerateBouquetNamesView,
)
# Каталог
from .catalog_views import CatalogView
@@ -149,6 +157,7 @@ __all__ = [
'productkit_photo_set_main',
'productkit_photo_move_up',
'productkit_photo_move_down',
'productkit_photos_delete_bulk',
# Управление фотографиями Category
'category_photo_delete',
@@ -225,6 +234,8 @@ __all__ = [
'validate_kit_cost',
'create_temporary_kit_api',
'create_tag_api',
'RandomBouquetNamesView',
'GenerateBouquetNamesView',
# Каталог
'CatalogView',

View File

@@ -1800,3 +1800,72 @@ def bulk_update_categories(request):
'success': False,
'message': f'Произошла ошибка: {str(e)}'
}, status=500)
# ========== Генератор названий букетов ==========
from django.views import View
from ..models import BouquetName
from ..services import BouquetNameGenerator
class RandomBouquetNamesView(View):
"""Возвращает случайные названия из базы"""
def get(self, request):
count = int(request.GET.get('count', 3))
# Ограничиваем максимум до 100
count = min(count, 100)
# Получаем случайные названия с ID (любые, не только одобренные)
queryset = BouquetName.objects.order_by('?')[:count]
names_data = [{'id': obj.id, 'name': obj.name} for obj in queryset]
return JsonResponse({'names': names_data})
class GenerateBouquetNamesView(View):
"""Генерирует новые названия через LLM и сохраняет в базу"""
def post(self, request):
count = int(request.POST.get('count', 10))
# Ограничиваем максимум до 500
count = min(count, 500)
generator = BouquetNameGenerator()
success, msg, data = generator.generate_and_store(
count=count,
language='russian'
)
if success:
return JsonResponse({
'success': True,
'message': msg,
'count': len(data.get('names', []))
})
else:
return JsonResponse({'success': False, 'error': msg}, status=400)
class DeleteBouquetNameView(View):
"""Удаляет конкретное название из базы"""
def delete(self, request, pk):
try:
name_obj = BouquetName.objects.get(pk=pk)
name_obj.delete()
return JsonResponse({'success': True})
except BouquetName.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Название не найдено'}, status=404)
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)
class GetBouquetNamesCountView(View):
"""Возвращает количество названий в базе"""
def get(self, request):
count = BouquetName.objects.count()
return JsonResponse({'count': count})

View File

@@ -41,7 +41,7 @@ class TreeItem:
if item_type == 'product':
self.price = obj.sale_price
elif item_type == 'kit':
self.price = obj.get_sale_price()
self.price = obj.actual_price
else:
self.price = None

View File

@@ -380,3 +380,67 @@ def product_photos_delete_bulk(request):
'success': False,
'error': f'Ошибка сервера: {str(e)}'
}, status=500)
@require_http_methods(["POST"])
@login_required
def productkit_photos_delete_bulk(request):
"""
AJAX endpoint для массового удаления фотографий комплекта.
Ожидает JSON: {photo_ids: [1, 2, 3]}
Возвращает JSON: {success: true, deleted: 3} или {success: false, error: "..."}
"""
# Проверка прав доступа
if not request.user.has_perm('products.change_productkit'):
return JsonResponse({
'success': False,
'error': 'У вас нет прав для удаления фотографий'
}, status=403)
try:
# Получаем список photo_ids из JSON тела запроса
data = json.loads(request.body)
photo_ids = data.get('photo_ids', [])
if not photo_ids or not isinstance(photo_ids, list):
return JsonResponse({
'success': False,
'error': 'Неверный формат: требуется список photo_ids'
}, status=400)
# Удаляем фотографии
deleted_count = 0
for photo_id in photo_ids:
try:
photo = ProductKitPhoto.objects.get(pk=photo_id)
photo.delete() # Это вызовет ImageProcessor.delete_all_versions()
deleted_count += 1
except ProductKitPhoto.DoesNotExist:
# Если фото не найдена, просто пропускаем
continue
except Exception as e:
# Логируем ошибку но продолжаем удаление остальных
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error deleting kit photo {photo_id}: {str(e)}", exc_info=True)
continue
return JsonResponse({
'success': True,
'deleted': deleted_count
})
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Неверный JSON формат'
}, status=400)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Bulk kit photo deletion error: {str(e)}", exc_info=True)
return JsonResponse({
'success': False,
'error': f'Ошибка сервера: {str(e)}'
}, status=500)

View File

@@ -208,6 +208,15 @@ class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailVie
# Единицы продажи (активные, отсортированные)
context['sales_units'] = self.object.sales_units.filter(is_active=True).order_by('position', 'name')
# Комплекты, в которых этот товар используется как единица продажи
context['kit_items_using_sales_units'] = self.object.kit_items_using_as_sales_unit.select_related('kit', 'sales_unit').prefetch_related('kit__photos')
# Комплекты, в которых этот товар используется напрямую
context['kit_items_using_products'] = self.object.kit_items_direct.select_related('kit').prefetch_related('kit__photos')
# Комплекты, в которых этот товар используется как часть группы вариантов
context['variant_group_kit_items'] = self.object.variant_group_items.select_related('variant_group').prefetch_related('variant_group__kit_items__kit__photos')
return context

View File

@@ -9,9 +9,10 @@ from django.shortcuts import redirect
from django.db import transaction, IntegrityError
from user_roles.mixins import ManagerOwnerRequiredMixin
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName, ProductSalesUnit
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
from .utils import handle_photos
import os
class ProductKitListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
@@ -97,6 +98,37 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
form_class = ProductKitForm
template_name = 'products/productkit_create.html'
def get_initial(self):
initial = super().get_initial()
copy_id = self.request.GET.get('copy_from')
if copy_id:
try:
kit = ProductKit.objects.get(pk=copy_id)
# Generate unique name
base_name = f"{kit.name} (Копия)"
new_name = base_name
counter = 1
while ProductKit.objects.filter(name=new_name).exists():
counter += 1
new_name = f"{base_name} {counter}"
initial.update({
'name': new_name,
'description': kit.description,
'short_description': kit.short_description,
'categories': list(kit.categories.values_list('pk', flat=True)),
'tags': list(kit.tags.values_list('pk', flat=True)),
'sale_price': kit.sale_price,
'price_adjustment_type': kit.price_adjustment_type,
'price_adjustment_value': kit.price_adjustment_value,
'external_category': kit.external_category,
'status': 'active', # Default to active for new kits
})
except ProductKit.DoesNotExist:
pass
return initial
def post(self, request, *args, **kwargs):
"""
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
@@ -113,6 +145,12 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
# Извлекаем числовой ID из "product_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
elif key.endswith('-sales_unit') and post_data[key]:
value = post_data[key]
if '_' in value:
# Извлекаем числовой ID из "sales_unit_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
# Заменяем request.POST на очищенные данные
request.POST = post_data
@@ -126,9 +164,9 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
from ..models import Product, ProductVariantGroup
selected_products = {}
selected_variants = {}
selected_sales_units = {}
for key, value in self.request.POST.items():
if '-product' in key and value:
@@ -168,11 +206,121 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
except ProductVariantGroup.DoesNotExist:
pass
if '-sales_unit' in key and value:
try:
sales_unit = ProductSalesUnit.objects.select_related('product').get(id=value)
text = f"{sales_unit.name} ({sales_unit.product.name})"
# Получаем actual_price: приоритет sale_price > price
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[key] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
except ProductSalesUnit.DoesNotExist:
pass
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
else:
# COPY KIT LOGIC
copy_id = self.request.GET.get('copy_from')
initial_items = []
selected_products = {}
selected_variants = {}
selected_sales_units = {}
if copy_id:
try:
source_kit = ProductKit.objects.get(pk=copy_id)
for item in source_kit.kit_items.all():
item_data = {
'quantity': item.quantity,
# Delete flag is false by default
}
form_prefix = f"kititem-{len(initial_items)}"
if item.product:
item_data['product'] = item.product
# Select2 prefill
product = item.product
text = product.name
if product.sku:
text += f" ({product.sku})"
actual_price = product.sale_price if product.sale_price else product.price
selected_products[f"{form_prefix}-product"] = {
'id': product.id,
'text': text,
'price': str(product.price) if product.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.sales_unit:
item_data['sales_unit'] = item.sales_unit
# Select2 prefill
sales_unit = item.sales_unit
text = f"{sales_unit.name} ({sales_unit.product.name})"
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[f"{form_prefix}-sales_unit"] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.variant_group:
item_data['variant_group'] = item.variant_group
# Select2 prefill
variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=item.variant_group.id)
variant_price = variant_group.price or 0
count = variant_group.items.count()
selected_variants[f"{form_prefix}-variant_group"] = {
'id': variant_group.id,
'text': f"{variant_group.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}
initial_items.append(item_data)
except ProductKit.DoesNotExist:
pass
if initial_items:
context['kititem_formset'] = KitItemFormSetCreate(
prefix='kititem',
initial=initial_items
)
context['kititem_formset'].extra = len(initial_items)
else:
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
# Pass Select2 data to context
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
# Pass source photos if copying
if copy_id:
try:
source_kit = ProductKit.objects.prefetch_related('photos').get(pk=copy_id)
photos = source_kit.photos.all().order_by('order')
print(f"DEBUG: Found {photos.count()} source photos for kit {copy_id}")
context['source_photos'] = photos
except ProductKit.DoesNotExist:
print(f"DEBUG: Source kit {copy_id} not found")
pass
# Количество названий букетов в базе
context['bouquet_names_count'] = BouquetName.objects.count()
return context
def form_valid(self, form):
@@ -208,6 +356,48 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
# Обработка фотографий
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
# Handle copied photos
copied_photo_ids = self.request.POST.getlist('copied_photos')
print(f"DEBUG: copied_photo_ids in POST: {copied_photo_ids}")
if copied_photo_ids:
from django.core.files.base import ContentFile
original_photos = ProductKitPhoto.objects.filter(id__in=copied_photo_ids)
print(f"DEBUG: Found {original_photos.count()} original photos to copy")
# Get max order from existing photos (uploaded via handle_photos)
from django.db.models import Max
max_order = self.object.photos.aggregate(Max('order'))['order__max']
next_order = 0 if max_order is None else max_order + 1
print(f"DEBUG: Starting order for copies: {next_order}")
for photo in original_photos:
try:
# Open the original image file
if photo.image:
print(f"DEBUG: Processing photo {photo.id}: {photo.image.name}")
with photo.image.open('rb') as f:
image_content = f.read()
# Create a new ContentFile
new_image_name = f"copy_{self.object.id}_{os.path.basename(photo.image.name)}"
print(f"DEBUG: New image name: {new_image_name}")
# Create new photo instance
new_photo = ProductKitPhoto(kit=self.object, order=next_order)
# Save the image file (this also saves the model instance)
new_photo.image.save(new_image_name, ContentFile(image_content))
print(f"DEBUG: Successfully saved copy for photo {photo.id}")
next_order += 1
else:
print(f"DEBUG: Photo {photo.id} has no image file")
except Exception as e:
print(f"Error copying photo {photo.id}: {e}")
import traceback
traceback.print_exc()
continue
messages.success(
self.request,
f'Комплект "{self.object.name}" успешно создан!'
@@ -271,6 +461,12 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
# Извлекаем числовой ID из "product_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
elif key.endswith('-sales_unit') and post_data[key]:
value = post_data[key]
if '_' in value:
# Извлекаем числовой ID из "sales_unit_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
# Заменяем request.POST на очищенные данные
request.POST = post_data
@@ -284,8 +480,10 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
# При ошибке валидации - подготавливаем данные для Select2
from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {}
selected_variants = {}
selected_sales_units = {}
for key, value in self.request.POST.items():
if '-product' in key and value:
@@ -328,14 +526,35 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
except ProductVariantGroup.DoesNotExist:
pass
if '-sales_unit' in key and value:
try:
# Очищаем ID от префикса если есть
numeric_value = value.split('_')[1] if '_' in value else value
sales_unit = ProductSalesUnit.objects.select_related('product').get(id=numeric_value)
text = f"{sales_unit.name} ({sales_unit.product.name})"
# Получаем actual_price: приоритет sale_price > price
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[key] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
except ProductSalesUnit.DoesNotExist:
pass
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
else:
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
# Подготавливаем данные для предзагрузки в Select2
from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {}
selected_variants = {}
selected_sales_units = {}
for item in self.object.kit_items.all():
form_prefix = f"kititem-{item.id}"
@@ -354,6 +573,17 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
'actual_price': str(actual_price) if actual_price else '0'
}
if item.sales_unit:
sales_unit = item.sales_unit
text = f"{sales_unit.name} ({sales_unit.product.name})"
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[f"{form_prefix}-sales_unit"] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.variant_group:
variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product'
@@ -373,6 +603,7 @@ class ProductKitUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Update
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()

View File

@@ -63,6 +63,11 @@ def unit_of_measure_create(request):
"""
Создание новой единицы измерения
"""
# Проверка: PlatformAdmin не имеет доступа к бизнес-данным тенантов
if request.user.__class__.__name__ == 'PlatformAdmin':
messages.error(request, 'У вас недостаточно прав для выполнения этого действия')
return redirect('products:unit-list')
if request.method == 'POST':
form = UnitOfMeasureForm(request.POST)
if form.is_valid():
@@ -85,6 +90,11 @@ def unit_of_measure_update(request, pk):
"""
Редактирование единицы измерения
"""
# Проверка: PlatformAdmin не имеет доступа к бизнес-данным тенантов
if request.user.__class__.__name__ == 'PlatformAdmin':
messages.error(request, 'У вас недостаточно прав для выполнения этого действия')
return redirect('products:unit-list')
unit = get_object_or_404(UnitOfMeasure, pk=pk)
if request.method == 'POST':
@@ -110,11 +120,16 @@ def unit_of_measure_delete(request, pk):
"""
Удаление единицы измерения
"""
# Проверка: PlatformAdmin не имеет доступа к бизнес-данным тенантов
if request.user.__class__.__name__ == 'PlatformAdmin':
messages.error(request, 'У вас недостаточно прав для выполнения этого действия')
return redirect('products:unit-list')
unit = get_object_or_404(UnitOfMeasure, pk=pk)
# Проверяем использование
products_using = unit.products.count()
sales_units_using = unit.productsalesunit_set.count()
sales_units_using = ProductSalesUnit.objects.filter(product__base_unit=unit).count()
can_delete = products_using == 0 and sales_units_using == 0

View File

@@ -0,0 +1,120 @@
import os
import sys
import json
import django
from decimal import Decimal
# Setup Django
sys.path.append(os.getcwd())
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django.setup()
from django.test import RequestFactory
from django.contrib.auth import get_user_model
from django.db import connection
from customers.models import Customer
from inventory.models import Warehouse, Sale
from products.models import Product, UnitOfMeasure
from pos.views import pos_checkout
from orders.models import OrderStatus
def run():
# Setup Data
User = get_user_model()
user = User.objects.first()
if not user:
print("No user found")
return
# Create/Get Customer
customer, _ = Customer.objects.get_or_create(
name="Test Customer",
defaults={'phone': '+375291112233'}
)
# Create/Get Warehouse
warehouse, _ = Warehouse.objects.get_or_create(
name="Test Warehouse",
defaults={'is_active': True}
)
# Create product
product, _ = Product.objects.get_or_create(
name="Test Product Debug",
defaults={
'sku': 'DEBUG001',
'buying_price': 10,
'actual_price': 50,
'warehouse': warehouse
}
)
product.actual_price = 50
product.save()
# Ensure OrderStatus exists
OrderStatus.objects.get_or_create(code='completed', is_system=True, defaults={'name': 'Completed', 'is_positive_end': True})
OrderStatus.objects.get_or_create(code='draft', is_system=True, defaults={'name': 'Draft'})
# Prepare Request
factory = RequestFactory()
payload = {
"customer_id": customer.id,
"warehouse_id": warehouse.id,
"items": [
{
"type": "product",
"id": product.id,
"quantity": 1,
"price": 100.00, # Custom price
"quantity_base": 1
}
],
"payments": [
{"payment_method": "cash", "amount": 100.00}
],
"notes": "Debug Sale"
}
request = factory.post(
'/pos/api/checkout/',
data=json.dumps(payload),
content_type='application/json'
)
request.user = user
print("Executing pos_checkout...")
response = pos_checkout(request)
print(f"Response: {response.content}")
# Verify Sale
sales = Sale.objects.filter(product=product).order_by('-id')[:1]
if sales:
sale = sales[0]
print(f"Sale created. ID: {sale.id}")
print(f"Sale Quantity: {sale.quantity}")
print(f"Sale Price: {sale.sale_price}")
if sale.sale_price == 0:
print("FAILURE: Sale price is 0!")
else:
print(f"SUCCESS: Sale price is {sale.sale_price}")
else:
print("FAILURE: No Sale created!")
if __name__ == "__main__":
from django_tenants.utils import schema_context
# Replace with actual schema name if needed, assuming 'public' for now or the default tenant
# Since I don't know the tenant, I'll try to run in the current context.
# But usually need to set schema.
# Let's try to find a tenant.
from tenants.models import Client
tenant = Client.objects.first()
if tenant:
print(f"Running in tenant: {tenant.schema_name}")
with schema_context(tenant.schema_name):
run()
else:
print("No tenant found, running in public?")
run()

View File

@@ -153,8 +153,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
statusBadge.style.display = 'inline';
// Построить форму
buildForm(data.fields, data.data || {});
// Построить форму (теперь асинхронно)
await buildForm(data.fields, data.data || {});
// Показать/скрыть кнопку тестирования
const testBtn = document.getElementById('test-connection-btn');
@@ -173,11 +173,11 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Построение формы из метаданных полей
function buildForm(fields, data) {
async function buildForm(fields, data) {
const container = document.getElementById('settings-fields');
container.innerHTML = '';
fields.forEach(field => {
for (const field of fields) {
const div = document.createElement('div');
div.className = 'mb-3';
@@ -189,7 +189,14 @@ document.addEventListener('DOMContentLoaded', function() {
<label class="form-check-label" for="field-${field.name}">${field.label}</label>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
} else if (field.type === 'select') {
let optionsHtml = '';
if (field.dynamic_choices) {
// Динамическая загрузка options
optionsHtml = '<option value="">Загрузка моделей...</option>';
div.innerHTML = `
<label class="form-label" for="field-${field.name}">
${field.label}
@@ -198,18 +205,66 @@ document.addEventListener('DOMContentLoaded', function() {
<select class="form-select" id="field-${field.name}"
name="${field.name}"
${field.required ? 'required' : ''}>
${field.choices.map(choice => `
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
${choice[1]}
</option>
`).join('')}
${optionsHtml}
</select>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
container.appendChild(div);
// Асинхронная загрузка
const select = div.querySelector('select');
try {
const response = await fetch(field.choices_url);
const result = await response.json();
if (result.error) {
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
console.error(result.error);
} else {
select.innerHTML = result.models.map(m =>
`<option value="${m.id}">${m.name}</option>`
).join('');
if (data[field.name]) {
select.value = data[field.name];
}
}
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
console.error('Error loading models:', error);
}
} else {
// Статический select (для temperature)
optionsHtml = field.choices.map(choice => `
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
${choice[1]}
</option>
`).join('');
div.innerHTML = `
<label class="form-label" for="field-${field.name}">
${field.label}
${field.required ? '<span class="text-danger">*</span>' : ''}
</label>
<select class="form-select" id="field-${field.name}"
name="${field.name}"
${field.required ? 'required' : ''}>
${optionsHtml}
</select>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
}
} else {
// text, password, url
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
const value = data[field.name] || '';
const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : '';
let value = data[field.name] || '';
const isMasked = value === '••••••••';
const placeholder = isMasked ? 'Ключ сохранён. Оставьте пустым, чтобы не менять' : '';
// Для password полей показываем звёздочки (8 штук как индикатор сохранённого ключа)
const inputValue = (field.type === 'password' && isMasked) ? '********' : value;
div.innerHTML = `
<label class="form-label" for="field-${field.name}">
@@ -217,15 +272,17 @@ document.addEventListener('DOMContentLoaded', function() {
${field.required ? '<span class="text-danger">*</span>' : ''}
</label>
<input type="${inputType}" class="form-control" id="field-${field.name}"
name="${field.name}" value="${value !== '........' ? value : ''}"
name="${field.name}" value="${inputValue}"
placeholder="${placeholder}"
${field.required ? 'required' : ''}>
${field.required && !isMasked ? 'required' : ''}>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
}
if (field.type !== 'select' || !field.dynamic_choices) {
container.appendChild(div);
});
}
}
}
// Обработчик клика на интеграцию
@@ -313,9 +370,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Собрать данные формы
for (const [key, value] of formData.entries()) {
// Пропустить пустые password поля (не менять если не введено)
// Пропустить пустые password поля или звёздочки (не менять если не введено новое значение)
const input = document.getElementById(`field-${key}`);
if (input && input.type === 'password' && !value) continue;
if (input && input.type === 'password' && (!value || value === '********')) continue;
data[key] = value;
}

View File

@@ -20,14 +20,14 @@
{% if user.is_authenticated %}
{% comment %}Показываем меню tenant приложений только если мы не на странице setup-password (public схема){% endcomment %}
{% if 'setup-password' not in request.path %}
<!-- 📦 Товары -->
<!-- Товары -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'products' %}active{% endif %}" href="#" id="productsDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
📦 Товары
Товары
</a>
<ul class="dropdown-menu" aria-labelledby="productsDropdown">
<li><a class="dropdown-item" href="{% url 'products:products-list' %}">Все товары</a></li>
<li><a class="dropdown-item" href="{% url 'products:catalog' %}"><i class="bi bi-grid-3x3-gap"></i> Каталог</a></li>
<li><a class="dropdown-item" href="{% url 'products:catalog' %}">Каталог</a></li>
<li><a class="dropdown-item" href="{% url 'products:configurableproduct-list' %}">Вариативные товары</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li>
@@ -35,15 +35,15 @@
<li><a class="dropdown-item" href="{% url 'products:variantgroup-list' %}">Варианты (группы)</a></li>
<li><a class="dropdown-item" href="{% url 'products:attribute-list' %}">Атрибуты</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'products:unit-list' %}"><i class="bi bi-rulers"></i> Единицы измерения</a></li>
<li><a class="dropdown-item" href="{% url 'products:sales-unit-list' %}"><i class="bi bi-box-seam"></i> Единицы продажи</a></li>
<li><a class="dropdown-item" href="{% url 'products:unit-list' %}">Единицы измерения</a></li>
<li><a class="dropdown-item" href="{% url 'products:sales-unit-list' %}">Единицы продажи</a></li>
</ul>
</li>
<!-- 📋 Заказы -->
<!-- Заказы -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'orders' %}active{% endif %}" href="{% url 'orders:order-list' %}" id="ordersDropdown">
📋 Заказы
Заказы
</a>
<ul class="dropdown-menu" aria-labelledby="ordersDropdown">
<li><a class="dropdown-item" href="{% url 'orders:order-list' %}">Список заказов</a></li>
@@ -52,17 +52,17 @@
</ul>
</li>
<!-- 👥 Клиенты -->
<!-- Клиенты -->
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'customers' %}active{% endif %}" href="{% url 'customers:customer-list' %}">
👥 Клиенты
Клиенты
</a>
</li>
<!-- 📦 Склад -->
<!-- Склад -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.resolver_match.namespace == 'inventory' %}active{% endif %}" href="#" id="inventoryDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
🏭 Склад
Склад
</a>
<ul class="dropdown-menu" aria-labelledby="inventoryDropdown">
<li><a class="dropdown-item" href="{% url 'inventory:inventory-home' %}">Управление складом</a></li>
@@ -70,37 +70,37 @@
</ul>
</li>
<!-- 💰 Касса -->
<!-- Касса -->
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'pos' %}active{% endif %}" href="{% url 'pos:terminal' %}">
💰 Касса
Касса
</a>
</li>
{% endif %}
<!-- ⚙️ Настройки (только для owner/superuser) -->
<!-- Настройки (только для owner/superuser) -->
{% if request.user.is_owner or request.user.is_superuser %}
<li class="nav-item">
{% if request.tenant %}
<a class="nav-link {% if request.resolver_match.namespace == 'system_settings' or 'user_roles' in request.resolver_match.app_names %}active{% endif %}"
href="{% url 'system_settings:settings' %}">
⚙️ Настройки
Настройки
</a>
{% else %}
<a class="nav-link" href="/platform/dashboard">
⚙️ Настройки
Настройки
</a>
{% endif %}
</li>
{% endif %}
<!-- 🔧 Debug (только для superuser) -->
{% if user.is_superuser %}
<!-- Debug (для owner или manager) -->
{% if user.is_owner or user.is_manager %}
{% url 'inventory:debug_page' as debug_url %}
{% if debug_url %}
<li class="nav-item">
<a class="nav-link" href="{{ debug_url }}" style="color: #dc3545; font-weight: bold;">
🔧 Debug
Debug
</a>
</li>
{% endif %}

29
prepare_js.py Normal file
View File

@@ -0,0 +1,29 @@
import re
file_path = r'c:\Users\team_\Desktop\test_qwen\myproject\products\templates\products\productkit_edit.html'
try:
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
except FileNotFoundError:
print(f"File not found: {file_path}")
exit(1)
# Extract script part (approx lines 451 to 1321)
# Note: lines are 0-indexed in list
script_lines = lines[450:1322]
script_content = "".join(script_lines)
# Replace Django tags
# Replace {% ... %} with "TEMPLATETAG"
script_content = re.sub(r'\{%.*?%\}', '"TEMPLATETAG"', script_content)
# Replace {{ ... }} with "VARIABLE" or {}
script_content = re.sub(r'\{\{.*?\}\}', '{}', script_content)
# Save to temp js file
temp_js_path = r'c:\Users\team_\Desktop\test_qwen\temp_check.js'
with open(temp_js_path, 'w', encoding='utf-8') as f:
f.write(script_content)
print(f"Written to {temp_js_path}")

54
test_bouquet_api.py Normal file
View File

@@ -0,0 +1,54 @@
"""
Простой тест для проверки API-эндпоинтов генератора названий букетов
"""
import os
import sys
import django
from django.test import Client
# Настройка Django
sys.path.append(r'c:\Users\team_\Desktop\test_qwen')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()
def test_bouquet_api_endpoints():
client = Client()
print("Тестируем API-эндпоинты для названий букетов...")
# Тестируем получение случайных названий
print("\n1. Тестируем получение случайных названий...")
response = client.get('/products/api/bouquet-names/random/?count=3')
print(f"Статус: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"Получено названий: {len(data.get('names', []))}")
print(f"Примеры: {data.get('names', [])[:2]}")
else:
print(f"Ошибка: {response.content.decode()}")
# Тестируем получение количества названий
print("\n2. Тестируем получение количества названий...")
response = client.get('/products/api/bouquet-names/count/')
print(f"Статус: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"Количество названий в базе: {data.get('count', 0)}")
else:
print(f"Ошибка: {response.content.decode()}")
# Попробуем сгенерировать названия (только если есть настройки для AI)
print("\n3. Попробуем сгенерировать названия...")
try:
response = client.post('/products/api/bouquet-names/generate/', {'count': 5})
print(f"Статус: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"Результат генерации: {data}")
else:
print(f"Ошибка генерации: {response.content.decode()}")
except Exception as e:
print(f"Исключение при генерации: {e}")
if __name__ == "__main__":
test_bouquet_api_endpoints()

View File

@@ -1,7 +1,7 @@
#!/bin/sh
# Скрипт деплоя для octopus (FIXED v4 - Auto-Update Compose)
LOG_FILE="/tmp/deploy-octopus.log"
HASH_FILE="/tmp/requirements-hash.txt"
# Скрипт деплоя для octopus (FIXED v7 - Correct paths and docker-compose)
LOG_FILE="/Volume1/DockerAppsData/mixapp/deploy-octopus.log"
HASH_FILE="/Volume1/DockerAppsData/mixapp/requirements-hash.txt"
DOCKER_COMPOSE_DIR="/Volume1/DockerYAML/mix"
APP_ROOT="/Volume1/DockerAppsData/mixapp/app"
@@ -11,7 +11,7 @@ echo "=== Deploy started at $(date) ===" >> "$LOG_FILE"
echo "Step 1: Git pull..." >> "$LOG_FILE"
docker exec git-cli sh -c "cd /git/octopus && git pull" >> "$LOG_FILE" 2>&1
# 2. Вычисляем общий хеш (requirements + docker config + docker-compose)
# 2. Вычисляем общий хеш (requirements + docker config + docker-compose.yml)
echo "Step 2: Checking for structural changes..." >> "$LOG_FILE"
NEW_HASH=$(docker exec git-cli sh -c "cd /git/octopus && cat myproject/requirements.txt docker/* docker/docker-compose.yml | md5sum" | awk '{print $1}')