feat(discounts, orders): рефакторинг системы скидок - единый источник правды
- Добавлен combine_mode в форму создания/редактирования скидок - Добавлена колонка "Объединение" в список скидок с иконками - Добавлен фильтр по режиму объединения скидок - Добавлена валидация: только одна exclusive скидка на заказ - Удалены дублирующие поля из Order и OrderItem: - applied_discount, applied_promo_code, discount_amount - Скидки теперь хранятся только в DiscountApplication - Добавлены свойства для обратной совместимости Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,6 @@ class DiscountApplier:
|
|||||||
from discounts.services.calculator import DiscountCalculator
|
from discounts.services.calculator import DiscountCalculator
|
||||||
|
|
||||||
# Удаляем предыдущую скидку на заказ
|
# Удаляем предыдущую скидку на заказ
|
||||||
if order.applied_promo_code:
|
|
||||||
DiscountApplier._remove_order_discount_only(order)
|
DiscountApplier._remove_order_discount_only(order)
|
||||||
|
|
||||||
# Рассчитываем скидку
|
# Рассчитываем скидку
|
||||||
@@ -50,21 +49,7 @@ class DiscountApplier:
|
|||||||
discounts_data = result['discounts']
|
discounts_data = result['discounts']
|
||||||
total_amount = result['total_amount']
|
total_amount = result['total_amount']
|
||||||
|
|
||||||
# Применяем первую скидку в applied_discount (для обратной совместимости с Order)
|
# Создаем записи о применении для каждой скидки в DiscountApplication
|
||||||
if discounts_data:
|
|
||||||
first_discount = discounts_data[0]['discount']
|
|
||||||
order.applied_discount = first_discount
|
|
||||||
order.applied_promo_code = promo.code
|
|
||||||
order.discount_amount = total_amount
|
|
||||||
order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount'])
|
|
||||||
|
|
||||||
# Пересчитываем total_amount
|
|
||||||
order.calculate_total()
|
|
||||||
|
|
||||||
# Регистрируем использование промокода
|
|
||||||
promo.record_usage(order.customer)
|
|
||||||
|
|
||||||
# Создаем записи о применении для каждой скидки
|
|
||||||
for disc_data in discounts_data:
|
for disc_data in discounts_data:
|
||||||
discount = disc_data['discount']
|
discount = disc_data['discount']
|
||||||
amount = disc_data['amount']
|
amount = disc_data['amount']
|
||||||
@@ -85,6 +70,12 @@ class DiscountApplier:
|
|||||||
discount.current_usage_count += 1
|
discount.current_usage_count += 1
|
||||||
discount.save(update_fields=['current_usage_count'])
|
discount.save(update_fields=['current_usage_count'])
|
||||||
|
|
||||||
|
# Пересчитываем total_amount (использует DiscountApplication)
|
||||||
|
order.calculate_total()
|
||||||
|
|
||||||
|
# Регистрируем использование промокода
|
||||||
|
promo.record_usage(order.customer)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'discounts': discounts_data,
|
'discounts': discounts_data,
|
||||||
@@ -124,12 +115,6 @@ class DiscountApplier:
|
|||||||
if order_result['discounts'] and not order_result['error']:
|
if order_result['discounts'] and not order_result['error']:
|
||||||
total_order_amount = order_result['total_amount']
|
total_order_amount = order_result['total_amount']
|
||||||
|
|
||||||
# Сохраняем первую скидку в applied_discount (для совместимости)
|
|
||||||
first_discount_data = order_result['discounts'][0]
|
|
||||||
order.applied_discount = first_discount_data['discount']
|
|
||||||
order.discount_amount = total_order_amount
|
|
||||||
order.save(update_fields=['applied_discount', 'discount_amount'])
|
|
||||||
|
|
||||||
# Создаем записи о применении для всех скидок
|
# Создаем записи о применении для всех скидок
|
||||||
for disc_data in order_result['discounts']:
|
for disc_data in order_result['discounts']:
|
||||||
discount = disc_data['discount']
|
discount = disc_data['discount']
|
||||||
@@ -167,12 +152,6 @@ class DiscountApplier:
|
|||||||
if item_result['discounts']:
|
if item_result['discounts']:
|
||||||
total_item_amount = item_result['total_amount']
|
total_item_amount = item_result['total_amount']
|
||||||
|
|
||||||
# Сохраняем первую скидку в applied_discount (для совместимости)
|
|
||||||
first_discount_data = item_result['discounts'][0]
|
|
||||||
item.applied_discount = first_discount_data['discount']
|
|
||||||
item.discount_amount = total_item_amount
|
|
||||||
item.save(update_fields=['applied_discount', 'discount_amount'])
|
|
||||||
|
|
||||||
# Создаем записи о применении для всех скидок
|
# Создаем записи о применении для всех скидок
|
||||||
base_amount = item.price * item.quantity
|
base_amount = item.price * item.quantity
|
||||||
for disc_data in item_result['discounts']:
|
for disc_data in item_result['discounts']:
|
||||||
@@ -186,7 +165,7 @@ class DiscountApplier:
|
|||||||
target='order_item',
|
target='order_item',
|
||||||
base_amount=base_amount,
|
base_amount=base_amount,
|
||||||
discount_amount=amount,
|
discount_amount=amount,
|
||||||
final_amount=item.get_total_price(),
|
final_amount=base_amount - amount,
|
||||||
customer=order.customer,
|
customer=order.customer,
|
||||||
applied_by=user
|
applied_by=user
|
||||||
)
|
)
|
||||||
@@ -216,14 +195,6 @@ class DiscountApplier:
|
|||||||
Args:
|
Args:
|
||||||
order: Order
|
order: Order
|
||||||
"""
|
"""
|
||||||
DiscountApplier._remove_order_discount_only(order)
|
|
||||||
|
|
||||||
# Удаляем скидки с позиций
|
|
||||||
order.items.update(
|
|
||||||
applied_discount=None,
|
|
||||||
discount_amount=Decimal('0')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Удаляем записи о применении
|
# Удаляем записи о применении
|
||||||
from discounts.models import DiscountApplication
|
from discounts.models import DiscountApplication
|
||||||
DiscountApplication.objects.filter(order=order).delete()
|
DiscountApplication.objects.filter(order=order).delete()
|
||||||
@@ -265,14 +236,6 @@ class DiscountApplier:
|
|||||||
# Рассчитываем сумму
|
# Рассчитываем сумму
|
||||||
discount_amount = discount.calculate_discount_amount(Decimal(order.subtotal))
|
discount_amount = discount.calculate_discount_amount(Decimal(order.subtotal))
|
||||||
|
|
||||||
# Применяем к заказу
|
|
||||||
order.applied_discount = discount
|
|
||||||
order.discount_amount = discount_amount
|
|
||||||
order.save(update_fields=['applied_discount', 'discount_amount'])
|
|
||||||
|
|
||||||
# Пересчитываем total_amount
|
|
||||||
order.calculate_total()
|
|
||||||
|
|
||||||
# Создаем запись о применении
|
# Создаем запись о применении
|
||||||
DiscountApplication.objects.create(
|
DiscountApplication.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
@@ -289,6 +252,9 @@ class DiscountApplier:
|
|||||||
discount.current_usage_count += 1
|
discount.current_usage_count += 1
|
||||||
discount.save(update_fields=['current_usage_count'])
|
discount.save(update_fields=['current_usage_count'])
|
||||||
|
|
||||||
|
# Пересчитываем total_amount
|
||||||
|
order.calculate_total()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'discount_amount': discount_amount
|
'discount_amount': discount_amount
|
||||||
@@ -307,7 +273,5 @@ class DiscountApplier:
|
|||||||
# Удаляем записи о применении скидок к заказу
|
# Удаляем записи о применении скидок к заказу
|
||||||
DiscountApplication.objects.filter(order=order, target='order').delete()
|
DiscountApplication.objects.filter(order=order, target='order').delete()
|
||||||
|
|
||||||
order.applied_discount = None
|
# Пересчитываем (order.discount_amount теперь свойство, берущее из DiscountApplication)
|
||||||
order.applied_promo_code = None
|
order.calculate_total()
|
||||||
order.discount_amount = Decimal('0')
|
|
||||||
order.save(update_fields=['applied_discount', 'applied_promo_code', 'discount_amount'])
|
|
||||||
|
|||||||
@@ -101,6 +101,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<option value="stack" {% if form.combine_mode.value == 'stack' %}selected{% endif %}>
|
||||||
|
📚 Складывать (суммировать с другими)
|
||||||
|
</option>
|
||||||
|
<option value="exclusive" {% if form.combine_mode.value == 'exclusive' %}selected{% endif %}>
|
||||||
|
🚫 Исключающая (отменяет остальные скидки)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Как эта скидка взаимодействует с другими активными скидками</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ограничения -->
|
<!-- Ограничения -->
|
||||||
<h5 class="mb-3 mt-4">Ограничения</h5>
|
<h5 class="mb-3 mt-4">Ограничения</h5>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
|
|||||||
@@ -60,6 +60,15 @@
|
|||||||
<option value="inactive" {% if current_filters.is_active == 'inactive' %}selected{% endif %}>Неактивные</option>
|
<option value="inactive" {% if current_filters.is_active == 'inactive' %}selected{% endif %}>Неактивные</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Объединение</label>
|
||||||
|
<select name="combine_mode" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="stack" {% if current_filters.combine_mode == 'stack' %}selected{% endif %}>Складывать</option>
|
||||||
|
<option value="max_only" {% if current_filters.combine_mode == 'max_only' %}selected{% endif %}>Максимум</option>
|
||||||
|
<option value="exclusive" {% if current_filters.combine_mode == 'exclusive' %}selected{% endif %}>Исключающая</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="col-md-3 d-flex align-items-end">
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
<button type="submit" class="btn btn-primary me-2">
|
<button type="submit" class="btn btn-primary me-2">
|
||||||
<i class="bi bi-funnel"></i> Применить
|
<i class="bi bi-funnel"></i> Применить
|
||||||
@@ -84,6 +93,7 @@
|
|||||||
<th>Значение</th>
|
<th>Значение</th>
|
||||||
<th>Область</th>
|
<th>Область</th>
|
||||||
<th>Авто</th>
|
<th>Авто</th>
|
||||||
|
<th>Объединение</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Промокоды</th>
|
<th>Промокоды</th>
|
||||||
<th>Использований</th>
|
<th>Использований</th>
|
||||||
@@ -129,6 +139,15 @@
|
|||||||
<i class="bi bi-dash-circle text-muted"></i>
|
<i class="bi bi-dash-circle text-muted"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if discount.combine_mode == 'stack' %}
|
||||||
|
<span class="badge bg-secondary" title="Складывать (суммировать)">📚 Склад.</span>
|
||||||
|
{% elif discount.combine_mode == 'max_only' %}
|
||||||
|
<span class="badge bg-info" title="Только максимум">🏆 Макс.</span>
|
||||||
|
{% elif discount.combine_mode == 'exclusive' %}
|
||||||
|
<span class="badge bg-danger" title="Исключающая (отменяет остальные)">🚫 Исключ.</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if discount.is_active %}
|
{% if discount.is_active %}
|
||||||
<span class="badge bg-success">Активна</span>
|
<span class="badge bg-success">Активна</span>
|
||||||
@@ -180,10 +199,10 @@
|
|||||||
<ul class="pagination justify-content-center">
|
<ul class="pagination justify-content-center">
|
||||||
{% if page_obj.has_previous %}
|
{% if page_obj.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page=1{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}">«</a>
|
<a class="page-link" href="?page=1{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}{% if current_filters.combine_mode %}&combine_mode={{ current_filters.combine_mode }}{% endif %}">«</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}">‹</a>
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}{% if current_filters.combine_mode %}&combine_mode={{ current_filters.combine_mode }}{% endif %}">‹</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -193,10 +212,10 @@
|
|||||||
|
|
||||||
{% if page_obj.has_next %}
|
{% if page_obj.has_next %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}">›</a>
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}{% if current_filters.combine_mode %}&combine_mode={{ current_filters.combine_mode }}{% endif %}">›</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}">»</a>
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if current_filters.search %}&search={{ current_filters.search }}{% endif %}{% if current_filters.type %}&type={{ current_filters.type }}{% endif %}{% if current_filters.scope %}&scope={{ current_filters.scope }}{% endif %}{% if current_filters.is_active %}&is_active={{ current_filters.is_active }}{% endif %}{% if current_filters.combine_mode %}&combine_mode={{ current_filters.combine_mode }}{% endif %}">»</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class DiscountListView(DiscountAccessMixin, ListView):
|
|||||||
discount_type = self.request.GET.get('type')
|
discount_type = self.request.GET.get('type')
|
||||||
scope = self.request.GET.get('scope')
|
scope = self.request.GET.get('scope')
|
||||||
is_active = self.request.GET.get('is_active')
|
is_active = self.request.GET.get('is_active')
|
||||||
|
combine_mode = self.request.GET.get('combine_mode')
|
||||||
search = self.request.GET.get('search')
|
search = self.request.GET.get('search')
|
||||||
|
|
||||||
if discount_type:
|
if discount_type:
|
||||||
@@ -79,6 +80,8 @@ class DiscountListView(DiscountAccessMixin, ListView):
|
|||||||
queryset = queryset.filter(scope=scope)
|
queryset = queryset.filter(scope=scope)
|
||||||
if is_active:
|
if is_active:
|
||||||
queryset = queryset.filter(is_active=(is_active == 'active'))
|
queryset = queryset.filter(is_active=(is_active == 'active'))
|
||||||
|
if combine_mode:
|
||||||
|
queryset = queryset.filter(combine_mode=combine_mode)
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(name__icontains=search) | Q(description__icontains=search)
|
Q(name__icontains=search) | Q(description__icontains=search)
|
||||||
@@ -92,6 +95,7 @@ class DiscountListView(DiscountAccessMixin, ListView):
|
|||||||
'type': self.request.GET.get('type', ''),
|
'type': self.request.GET.get('type', ''),
|
||||||
'scope': self.request.GET.get('scope', ''),
|
'scope': self.request.GET.get('scope', ''),
|
||||||
'is_active': self.request.GET.get('is_active', ''),
|
'is_active': self.request.GET.get('is_active', ''),
|
||||||
|
'combine_mode': self.request.GET.get('combine_mode', ''),
|
||||||
'search': self.request.GET.get('search', ''),
|
'search': self.request.GET.get('search', ''),
|
||||||
}
|
}
|
||||||
context['can_edit'] = self._can_edit()
|
context['can_edit'] = self._can_edit()
|
||||||
@@ -110,7 +114,7 @@ class DiscountCreateView(LoginRequiredMixin, RoleRequiredMixin, CreateView):
|
|||||||
template_name = 'discounts/discount_form.html'
|
template_name = 'discounts/discount_form.html'
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'description', 'discount_type', 'value', 'scope',
|
'name', 'description', 'discount_type', 'value', 'scope',
|
||||||
'is_auto', 'is_active', 'priority',
|
'is_auto', 'is_active', 'priority', 'combine_mode',
|
||||||
'start_date', 'end_date',
|
'start_date', 'end_date',
|
||||||
'min_order_amount', 'max_usage_count',
|
'min_order_amount', 'max_usage_count',
|
||||||
'products', 'categories', 'excluded_products',
|
'products', 'categories', 'excluded_products',
|
||||||
@@ -119,6 +123,21 @@ class DiscountCreateView(LoginRequiredMixin, RoleRequiredMixin, CreateView):
|
|||||||
success_url = reverse_lazy('system_settings:discounts:list')
|
success_url = reverse_lazy('system_settings:discounts:list')
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
|
# Валидация: нельзя создать больше одной активной exclusive скидки на заказ
|
||||||
|
if form.instance.combine_mode == 'exclusive' and form.instance.scope == 'order' and form.instance.is_active:
|
||||||
|
existing_exclusive = Discount.objects.filter(
|
||||||
|
combine_mode='exclusive',
|
||||||
|
scope='order',
|
||||||
|
is_active=True
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if existing_exclusive:
|
||||||
|
form.add_error(
|
||||||
|
'combine_mode',
|
||||||
|
'Уже существует активная исключающая скидка на заказ. Может быть только одна.'
|
||||||
|
)
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
form.instance.created_by = self.request.user
|
form.instance.created_by = self.request.user
|
||||||
messages.success(self.request, f'Скидка "{form.instance.name}" создана')
|
messages.success(self.request, f'Скидка "{form.instance.name}" создана')
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
@@ -142,7 +161,7 @@ class DiscountUpdateView(LoginRequiredMixin, RoleRequiredMixin, UpdateView):
|
|||||||
template_name = 'discounts/discount_form.html'
|
template_name = 'discounts/discount_form.html'
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'description', 'discount_type', 'value', 'scope',
|
'name', 'description', 'discount_type', 'value', 'scope',
|
||||||
'is_auto', 'is_active', 'priority',
|
'is_auto', 'is_active', 'priority', 'combine_mode',
|
||||||
'start_date', 'end_date',
|
'start_date', 'end_date',
|
||||||
'min_order_amount', 'max_usage_count',
|
'min_order_amount', 'max_usage_count',
|
||||||
'products', 'categories', 'excluded_products',
|
'products', 'categories', 'excluded_products',
|
||||||
@@ -151,6 +170,21 @@ class DiscountUpdateView(LoginRequiredMixin, RoleRequiredMixin, UpdateView):
|
|||||||
success_url = reverse_lazy('system_settings:discounts:list')
|
success_url = reverse_lazy('system_settings:discounts:list')
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
|
# Валидация: нельзя создать больше одной активной exclusive скидки на заказ
|
||||||
|
if form.instance.combine_mode == 'exclusive' and form.instance.scope == 'order' and form.instance.is_active:
|
||||||
|
existing_exclusive = Discount.objects.filter(
|
||||||
|
combine_mode='exclusive',
|
||||||
|
scope='order',
|
||||||
|
is_active=True
|
||||||
|
).exclude(pk=self.object.pk).exists()
|
||||||
|
|
||||||
|
if existing_exclusive:
|
||||||
|
form.add_error(
|
||||||
|
'combine_mode',
|
||||||
|
'Уже существует активная исключающая скидка на заказ. Может быть только одна.'
|
||||||
|
)
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
messages.success(self.request, f'Скидка "{form.instance.name}" обновлена')
|
messages.success(self.request, f'Скидка "{form.instance.name}" обновлена')
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ class OrderAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
'status',
|
'status',
|
||||||
'payment_status',
|
'payment_status',
|
||||||
'created_at',
|
'created_at',
|
||||||
'applied_discount',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
@@ -132,7 +131,6 @@ class OrderAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
'subtotal_display',
|
'subtotal_display',
|
||||||
'discount_display',
|
|
||||||
'amount_due',
|
'amount_due',
|
||||||
'payment_status',
|
'payment_status',
|
||||||
]
|
]
|
||||||
@@ -149,16 +147,13 @@ class OrderAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
('Скидки', {
|
('Скидки', {
|
||||||
'fields': (
|
'fields': (
|
||||||
'applied_discount',
|
'discount_display',
|
||||||
'applied_promo_code',
|
|
||||||
'discount_amount',
|
|
||||||
),
|
),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
('Оплата', {
|
('Оплата', {
|
||||||
'fields': (
|
'fields': (
|
||||||
'subtotal_display',
|
'subtotal_display',
|
||||||
'discount_display',
|
|
||||||
'total_amount',
|
'total_amount',
|
||||||
'amount_paid',
|
'amount_paid',
|
||||||
'amount_due',
|
'amount_due',
|
||||||
@@ -289,7 +284,6 @@ class OrderItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
list_filter = [
|
list_filter = [
|
||||||
'order__status',
|
'order__status',
|
||||||
'order__created_at',
|
'order__created_at',
|
||||||
'applied_discount',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
@@ -310,10 +304,6 @@ class OrderItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
|||||||
('Информация', {
|
('Информация', {
|
||||||
'fields': ('quantity', 'price', 'item_discount_display', 'get_total_price')
|
'fields': ('quantity', 'price', 'item_discount_display', 'get_total_price')
|
||||||
}),
|
}),
|
||||||
('Скидка', {
|
|
||||||
'fields': ('applied_discount', 'discount_amount'),
|
|
||||||
'classes': ('collapse',)
|
|
||||||
}),
|
|
||||||
('Системная информация', {
|
('Системная информация', {
|
||||||
'fields': ('created_at',),
|
'fields': ('created_at',),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2026-01-11 10:39
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orders', '0003_order_applied_discount_order_applied_promo_code_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='applied_discount',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='applied_promo_code',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='discount_amount',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='applied_discount',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='discount_amount',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -69,29 +69,10 @@ class Order(models.Model):
|
|||||||
help_text="Общая сумма заказа"
|
help_text="Общая сумма заказа"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Скидки
|
# === СКИДКИ ===
|
||||||
applied_discount = models.ForeignKey(
|
# Скидки хранятся в модели DiscountApplication (через related_name='discount_applications')
|
||||||
'discounts.Discount',
|
# Старые поля applied_discount, discount_amount, applied_promo_code УДАЛЕНЫ
|
||||||
on_delete=models.SET_NULL,
|
# Используйте свойства ниже для доступа к скидкам
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='orders',
|
|
||||||
verbose_name="Примененная скидка"
|
|
||||||
)
|
|
||||||
|
|
||||||
discount_amount = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Сумма скидки"
|
|
||||||
)
|
|
||||||
|
|
||||||
applied_promo_code = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Использованный промокод"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Частичная оплата
|
# Частичная оплата
|
||||||
amount_paid = models.DecimalField(
|
amount_paid = models.DecimalField(
|
||||||
@@ -407,6 +388,7 @@ class Order(models.Model):
|
|||||||
"""
|
"""
|
||||||
Пересчитывает итоговую сумму заказа.
|
Пересчитывает итоговую сумму заказа.
|
||||||
total_amount = subtotal + delivery_cost - discount_amount
|
total_amount = subtotal + delivery_cost - discount_amount
|
||||||
|
Скидка берётся из DiscountApplication
|
||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -417,7 +399,7 @@ class Order(models.Model):
|
|||||||
if hasattr(self, 'delivery'):
|
if hasattr(self, 'delivery'):
|
||||||
delivery_cost = self.delivery.cost
|
delivery_cost = self.delivery.cost
|
||||||
|
|
||||||
# Вычитаем скидку на весь заказ (если есть)
|
# Вычитаем скидку на весь заказ (из DiscountApplication через свойство)
|
||||||
order_discount = Decimal(str(self.discount_amount)) if self.discount_amount else Decimal('0')
|
order_discount = Decimal(str(self.discount_amount)) if self.discount_amount else Decimal('0')
|
||||||
|
|
||||||
self.total_amount = subtotal + delivery_cost - order_discount
|
self.total_amount = subtotal + delivery_cost - order_discount
|
||||||
@@ -432,6 +414,42 @@ class Order(models.Model):
|
|||||||
self.delivery.cost = 0
|
self.delivery.cost = 0
|
||||||
self.delivery.save(update_fields=['cost'])
|
self.delivery.save(update_fields=['cost'])
|
||||||
|
|
||||||
|
# === Свойства для доступа к скидкам (через DiscountApplication) ===
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_discounts(self):
|
||||||
|
"""Скидки на заказ (QuerySet DiscountApplication с target='order')"""
|
||||||
|
from discounts.models import DiscountApplication
|
||||||
|
return self.discount_applications.filter(target='order').select_related('discount', 'promo_code')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_discounts(self):
|
||||||
|
"""Скидки на позиции (QuerySet DiscountApplication с target='order_item')"""
|
||||||
|
from discounts.models import DiscountApplication
|
||||||
|
return self.discount_applications.filter(target='order_item').select_related('discount', 'order_item')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discount_amount(self):
|
||||||
|
"""Общая сумма скидки на заказ"""
|
||||||
|
from decimal import Decimal
|
||||||
|
return self.order_discounts.aggregate(
|
||||||
|
total=models.Sum('discount_amount')
|
||||||
|
)['total'] or Decimal('0')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def applied_discount(self):
|
||||||
|
"""Первая применённая скидка (для обратной совместимости)"""
|
||||||
|
first = self.order_discounts.first()
|
||||||
|
return first.discount if first else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def applied_promo_code(self):
|
||||||
|
"""Промокод из первой записи (для обратной совместимости)"""
|
||||||
|
first = self.order_discounts.first()
|
||||||
|
if first and first.promo_code:
|
||||||
|
return first.promo_code.code
|
||||||
|
return None
|
||||||
|
|
||||||
# === Свойства обратной совместимости для доступа к полям доставки ===
|
# === Свойства обратной совместимости для доступа к полям доставки ===
|
||||||
# Эти свойства обеспечивают доступ к полям Delivery через Order для обратной совместимости
|
# Эти свойства обеспечивают доступ к полям Delivery через Order для обратной совместимости
|
||||||
# после рефакторинга, когда поля доставки были перенесены в отдельную модель Delivery
|
# после рефакторинга, когда поля доставки были перенесены в отдельную модель Delivery
|
||||||
|
|||||||
@@ -82,22 +82,10 @@ class OrderItem(models.Model):
|
|||||||
help_text="True если цена была изменена вручную при создании заказа"
|
help_text="True если цена была изменена вручную при создании заказа"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Скидки
|
# === СКИДКИ ===
|
||||||
applied_discount = models.ForeignKey(
|
# Скидки хранятся в модели DiscountApplication (через related_name='discount_applications')
|
||||||
'discounts.Discount',
|
# Старые поля applied_discount, discount_amount УДАЛЕНЫ
|
||||||
on_delete=models.SET_NULL,
|
# Используйте свойства ниже для доступа к скидкам
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='order_items',
|
|
||||||
verbose_name="Скидка на позицию"
|
|
||||||
)
|
|
||||||
|
|
||||||
discount_amount = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Сумма скидки"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Витринные продажи
|
# Витринные продажи
|
||||||
is_from_showcase = models.BooleanField(
|
is_from_showcase = models.BooleanField(
|
||||||
@@ -254,3 +242,23 @@ class OrderItem(models.Model):
|
|||||||
if self.is_custom_price and self.original_price:
|
if self.is_custom_price and self.original_price:
|
||||||
return self.price - self.original_price
|
return self.price - self.original_price
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# === Свойства для доступа к скидкам (через DiscountApplication) ===
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_discounts(self):
|
||||||
|
"""Скидки на эту позицию (QuerySet DiscountApplication)"""
|
||||||
|
return self.discount_applications.filter(target='order_item').select_related('discount')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discount_amount(self):
|
||||||
|
"""Общая сумма скидки на позицию"""
|
||||||
|
from django.db.models import Sum
|
||||||
|
total = self.item_discounts.aggregate(total=Sum('discount_amount'))['total']
|
||||||
|
return total if total else Decimal('0')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def applied_discount(self):
|
||||||
|
"""Первая применённая скидка (для обратной совместимости)"""
|
||||||
|
first = self.item_discounts.first()
|
||||||
|
return first.discount if first else None
|
||||||
|
|||||||
@@ -1573,11 +1573,6 @@ def pos_checkout(request):
|
|||||||
discount_amount = min(discount_value, order.subtotal)
|
discount_amount = min(discount_value, order.subtotal)
|
||||||
discount_name = f'Произвольная скидка {discount_value} руб.'
|
discount_name = f'Произвольная скидка {discount_value} руб.'
|
||||||
|
|
||||||
order.discount_amount = discount_amount
|
|
||||||
order.applied_promo_code = discount_name # Сохраняем название в поле промокода
|
|
||||||
order.save(update_fields=['discount_amount', 'applied_promo_code'])
|
|
||||||
order.calculate_total()
|
|
||||||
|
|
||||||
# Создаем запись о применении в истории скидок
|
# Создаем запись о применении в истории скидок
|
||||||
from discounts.models import DiscountApplication
|
from discounts.models import DiscountApplication
|
||||||
DiscountApplication.objects.create(
|
DiscountApplication.objects.create(
|
||||||
@@ -1589,6 +1584,9 @@ def pos_checkout(request):
|
|||||||
customer=customer,
|
customer=customer,
|
||||||
applied_by=request.user
|
applied_by=request.user
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Пересчитываем total_amount (использует DiscountApplication)
|
||||||
|
order.calculate_total()
|
||||||
elif manual_discount_id:
|
elif manual_discount_id:
|
||||||
from discounts.services.applier import DiscountApplier
|
from discounts.services.applier import DiscountApplier
|
||||||
from discounts.models import Discount
|
from discounts.models import Discount
|
||||||
|
|||||||
Reference in New Issue
Block a user