From 977ee91feeb24579d034d524e7cd23b8024f595d Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 24 Jan 2026 01:37:27 +0300 Subject: [PATCH] feat(pos): add editable showcase creation date for kits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- myproject/pos/static/pos/js/terminal.js | 65 ++++++++++++++++- myproject/pos/templates/pos/terminal.html | 10 ++- myproject/pos/views.py | 71 +++++++++++++++++-- .../0003_productkit_showcase_created_at.py | 18 +++++ myproject/products/models/kits.py | 8 +++ myproject/products/utils/image_processor.py | 54 ++++++-------- 6 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 myproject/products/migrations/0003_productkit_showcase_created_at.py diff --git a/myproject/pos/static/pos/js/terminal.js b/myproject/pos/static/pos/js/terminal.js index 14e2bf8..0b2812f 100644 --- a/myproject/pos/static/pos/js/terminal.js +++ b/myproject/pos/static/pos/js/terminal.js @@ -98,6 +98,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}`; +} + // ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ ===== /** @@ -911,7 +942,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; @@ -922,7 +953,14 @@ function renderProducts() { let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`; let cartInfo = inCart > 0 ? ` 🛒${inCart}` : ''; - stock.innerHTML = `🌺 ${item.showcase_name} ${badgeText}${cartInfo}`; + // Добавляем отображение дней с момента создания как бейдж справа + const daysAgo = formatDaysAgo(item.showcase_created_at); + const daysBadge = daysAgo ? ` ${daysAgo}` : ''; + + stock.innerHTML = `${badgeText}${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) { @@ -1837,6 +1875,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; @@ -2275,8 +2326,9 @@ 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]; - + // Валидация if (!kitName) { alert('Введите название комплекта'); @@ -2329,6 +2381,12 @@ document.getElementById('confirmCreateTempKit').onclick = async () => { formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину } formData.append('description', description); + if (showcaseCreatedAt) { + formData.append('showcase_created_at', showcaseCreatedAt); + console.log('[DEBUG] Sending showcase_created_at:', showcaseCreatedAt); + } else { + console.log('[DEBUG] showcase_created_at is empty, NOT sending'); + } formData.append('items', JSON.stringify(items)); formData.append('price_adjustment_type', priceAdjustmentType); formData.append('price_adjustment_value', priceAdjustmentValue); @@ -2398,6 +2456,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'; diff --git a/myproject/pos/templates/pos/terminal.html b/myproject/pos/templates/pos/terminal.html index 87cca74..05682d4 100644 --- a/myproject/pos/templates/pos/terminal.html +++ b/myproject/pos/templates/pos/terminal.html @@ -218,7 +218,15 @@ - + + +
+ + + Оставьте пустым для текущего времени +
+
diff --git a/myproject/pos/views.py b/myproject/pos/views.py index 85d0584..c82c7d2 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -83,6 +83,7 @@ def get_showcase_kits_for_pos(): 'product_kit__price', 'product_kit__sale_price', 'product_kit__base_price', + 'product_kit__showcase_created_at', 'showcase_id', 'showcase__name' ).annotate( @@ -161,7 +162,9 @@ def get_showcase_kits_for_pos(): 'total_count': item['total_count'], # Всего на витрине (включая в корзине) 'showcase_item_ids': available_item_ids, # IDs только доступных # Флаг неактуальной цены - 'price_outdated': price_outdated + 'price_outdated': price_outdated, + # Дата размещения на витрине + 'showcase_created_at': item.get('product_kit__showcase_created_at') }) return showcase_kits @@ -1052,7 +1055,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: @@ -1087,7 +1091,8 @@ 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) @@ -1100,7 +1105,24 @@ def create_temp_kit_to_showcase(request): sale_price = None 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({ @@ -1161,7 +1183,8 @@ 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 для каждого товара из корзины @@ -1284,6 +1307,9 @@ def update_product_kit(request, kit_id): - photo: Новое фото (опционально) - remove_photo: '1' для удаления фото """ + import logging + logger = logging.getLogger(__name__) + try: kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True) @@ -1296,7 +1322,8 @@ 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) sale_price = None @@ -1307,7 +1334,32 @@ def update_product_kit(request, kit_id): sale_price = None except (ValueError, InvalidOperation): sale_price = None - + + # Showcase created at (опционально) + showcase_created_at = None + if showcase_created_at_str: + logger.warning(f"[DEBUG] showcase_created_at_str received: '{showcase_created_at_str}'") + try: + from datetime import datetime + showcase_created_at = datetime.fromisoformat(showcase_created_at_str) + logger.warning(f"[DEBUG] Parsed fromisoformat: {showcase_created_at}") + except ValueError as e: + logger.warning(f"[DEBUG] fromisoformat failed: {e}, trying strptime") + try: + showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M') + logger.warning(f"[DEBUG] Parsed strptime: {showcase_created_at}") + except ValueError as e2: + logger.warning(f"[DEBUG] strptime also failed: {e2}") + 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) + logger.warning(f"[DEBUG] Made aware: {showcase_created_at}") + + logger.warning(f"[DEBUG] Final showcase_created_at value: {showcase_created_at}") + # Валидация if not kit_name: return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400) @@ -1381,6 +1433,11 @@ 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 + logger.warning(f"[DEBUG] Saving kit.showcase_created_at = {kit.showcase_created_at}") + else: + logger.warning(f"[DEBUG] showcase_created_at is None, NOT updating field") kit.save() # Обновляем состав diff --git a/myproject/products/migrations/0003_productkit_showcase_created_at.py b/myproject/products/migrations/0003_productkit_showcase_created_at.py new file mode 100644 index 0000000..56c8dc6 --- /dev/null +++ b/myproject/products/migrations/0003_productkit_showcase_created_at.py @@ -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='Дата размещения на витрине'), + ), + ] diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index bc899b0..8f36ba4 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -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, diff --git a/myproject/products/utils/image_processor.py b/myproject/products/utils/image_processor.py index c89fd17..845add0 100644 --- a/myproject/products/utils/image_processor.py +++ b/myproject/products/utils/image_processor.py @@ -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):