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