Files
octopus/myproject/inventory/admin.py
Andrey Smakotin 0f09702094 Добавлена защита от удаления дефолтных Склада и Витрины
Проблема: при создании тенанта автоматически создаются дефолтные
Склад и Витрина. Если пользователь удалит их, система может сломаться:
POS, создание заказов и резервирование перестанут работать.

Решение: реализована строгая валидация + мягкое удаление для витрин.

Изменения в inventory/views/warehouse.py:
- Добавлена валидация перед деактивацией склада:
  * Блокировка деактивации последнего активного склада
  * Проверка ненулевых остатков товаров
  * Проверка активных резервов
  * Предупреждение при деактивации дефолтного склада

Изменения в inventory/views/showcase.py:
- ShowcaseListView: по умолчанию показывает только активные витрины
- ShowcaseDeleteView: изменена логика с жесткого на мягкое удаление
- Добавлена валидация перед деактивацией витрины:
  * Блокировка деактивации последней активной витрины склада
  * Проверка активных резервов
  * Проверка физических экземпляров комплектов (ShowcaseItem)
  * Предупреждение при деактивации дефолтной витрины

Изменения в inventory/forms_showcase.py:
- Проверка уникальности названия витрины учитывает только активные

Изменения в inventory/admin.py:
- ShowcaseAdmin: добавлены методы delete_model() и delete_queryset()
  для блокировки удаления последней витрины через админку
- WarehouseAdmin: добавлены методы delete_model() и delete_queryset()
  для блокировки удаления последнего склада через админку

Преимущества:
 Система не сломается - всегда есть хотя бы один активный склад/витрина
 Данные в безопасности - мягкое удаление для обеих сущностей
 Понятные сообщения об ошибках для пользователя
 Защита работает как в UI, так и в Django Admin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-03 19:52:01 +03:00

601 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Админка для приложения inventory.
Все модели tenant-only, поэтому используют TenantAdminOnlyMixin
для скрытия от public admin (localhost/admin/).
"""
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.db.models import Sum
from decimal import Decimal
from inventory.models import (
Warehouse, StockBatch, Sale, WriteOff,
Inventory, InventoryLine, Reservation, Stock,
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
TransformationOutput, TransferDocument, TransferDocumentItem
)
from tenants.admin_mixins import TenantAdminOnlyMixin
# ===== SHOWCASE =====
@admin.register(Showcase)
class ShowcaseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('name', 'warehouse', 'is_default', 'is_active', 'created_at')
list_filter = ('is_active', 'is_default', 'warehouse', 'created_at')
search_fields = ('name', 'warehouse__name')
date_hierarchy = 'created_at'
fieldsets = (
('Основная информация', {
'fields': ('name', 'warehouse', 'description', 'is_active', 'is_default')
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
def delete_model(self, request, obj):
"""
Защита от удаления последней активной витрины склада через админку.
Рекомендуется использовать деактивацию (is_active=False) вместо удаления.
"""
from django.contrib import messages
# Проверка: это последняя активная витрина склада?
active_showcases_count = Showcase.objects.filter(
warehouse=obj.warehouse,
is_active=True
).count()
if active_showcases_count <= 1 and obj.is_active:
messages.error(
request,
f'Невозможно удалить последнюю активную витрину склада "{obj.warehouse.name}" через админку. '
'Используйте деактивацию (is_active=False) вместо удаления.'
)
return
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
"""
Защита от массового удаления всех витрин склада через админку.
"""
from django.contrib import messages
# Группируем витрины по складам
warehouses = {}
for showcase in queryset:
if showcase.warehouse_id not in warehouses:
warehouses[showcase.warehouse_id] = {
'warehouse': showcase.warehouse,
'deleting_active': 0,
'total_active': Showcase.objects.filter(
warehouse=showcase.warehouse,
is_active=True
).count()
}
if showcase.is_active:
warehouses[showcase.warehouse_id]['deleting_active'] += 1
# Проверяем, не удаляем ли мы все витрины какого-либо склада
for wh_data in warehouses.values():
remaining = wh_data['total_active'] - wh_data['deleting_active']
if remaining < 1:
messages.error(
request,
f'Невозможно удалить все витрины склада "{wh_data["warehouse"].name}". '
'Хотя бы одна активная витрина должна остаться на каждом складе.'
)
return
super().delete_queryset(request, queryset)
# ===== WAREHOUSE =====
@admin.register(Warehouse)
class WarehouseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('name', 'is_default_display', 'is_active', 'created_at')
list_filter = ('is_active', 'is_default', 'created_at')
search_fields = ('name',)
fieldsets = (
('Основная информация', {
'fields': ('name', 'description', 'is_active', 'is_default')
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
def is_default_display(self, obj):
if obj.is_default:
return format_html('<span style="color: #ff9900; font-weight: bold;">★ По умолчанию</span>')
return '-'
is_default_display.short_description = 'По умолчанию'
def delete_model(self, request, obj):
"""
Защита от удаления последнего активного склада через админку.
Рекомендуется использовать деактивацию (is_active=False) вместо удаления.
"""
from django.contrib import messages
# Проверка: это последний активный склад?
active_warehouses_count = Warehouse.objects.filter(is_active=True).count()
if active_warehouses_count <= 1 and obj.is_active:
messages.error(
request,
'Невозможно удалить последний активный склад через админку. '
'Система требует наличия хотя бы одного активного склада для работы. '
'Используйте деактивацию (is_active=False) вместо удаления.'
)
return
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
"""
Защита от массового удаления всех складов через админку.
"""
from django.contrib import messages
# Подсчитываем текущее количество активных складов
active_count = Warehouse.objects.filter(is_active=True).count()
# Подсчитываем, сколько активных складов хотим удалить
deleting_active_count = queryset.filter(is_active=True).count()
# Проверяем, останется ли хотя бы один активный склад
if active_count - deleting_active_count < 1:
messages.error(
request,
'Невозможно удалить все активные склады. '
'Система требует наличия хотя бы одного активного склада для работы. '
'Используйте деактивацию (is_active=False) вместо удаления.'
)
return
super().delete_queryset(request, queryset)
# ===== STOCK BATCH =====
@admin.register(StockBatch)
class StockBatchAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('product', 'warehouse', 'quantity_display', 'cost_price', 'created_at', 'is_active')
list_filter = ('warehouse', 'is_active', 'created_at')
search_fields = ('product__name', 'product__sku', 'warehouse__name')
date_hierarchy = 'created_at'
fieldsets = (
('Партия', {
'fields': ('product', 'warehouse', 'quantity', 'is_active')
}),
('Финансы', {
'fields': ('cost_price',)
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
def quantity_display(self, obj):
if obj.quantity <= 0:
color = '#ff0000' # красный
elif obj.quantity < 10:
color = '#ff9900' # оранжевый
else:
color = '#008000' # зелёный
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
color,
f'{obj.quantity} шт'
)
quantity_display.short_description = 'Количество'
# ===== SALE BATCH ALLOCATION (INLINE) =====
class SaleBatchAllocationInline(admin.TabularInline):
model = SaleBatchAllocation
extra = 0
readonly_fields = ('batch', 'quantity', 'cost_price')
can_delete = False
fields = ('batch', 'quantity', 'cost_price')
# ===== SALE =====
@admin.register(Sale)
class SaleAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('product', 'warehouse', 'quantity', 'sale_price', 'order_display', 'processed_display', 'date')
list_filter = ('warehouse', 'processed', 'date')
search_fields = ('product__name', 'order__order_number')
date_hierarchy = 'date'
fieldsets = (
('Продажа', {
'fields': ('product', 'warehouse', 'quantity', 'sale_price', 'order')
}),
('Статус', {
'fields': ('processed',)
}),
('Документ', {
'fields': ('document_number',)
}),
('Дата', {
'fields': ('date',),
'classes': ('collapse',)
}),
)
readonly_fields = ('date',)
inlines = [SaleBatchAllocationInline]
def order_display(self, obj):
if obj.order:
return f"ORD-{obj.order.order_number}"
return "-"
order_display.short_description = 'Заказ'
def processed_display(self, obj):
if obj.processed:
return format_html('<span style="color: green;">✓ Обработана</span>')
return format_html('<span style="color: red;">✗ Ожидает</span>')
processed_display.short_description = 'Статус'
# ===== WRITE OFF =====
@admin.register(WriteOff)
class WriteOffAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('batch', 'quantity', 'reason_display', 'cost_price', 'date')
list_filter = ('reason', 'date', 'batch__warehouse')
search_fields = ('batch__product__name', 'document_number')
date_hierarchy = 'date'
fieldsets = (
('Списание', {
'fields': ('batch', 'quantity', 'reason', 'cost_price')
}),
('Документ', {
'fields': ('document_number', 'notes')
}),
('Дата', {
'fields': ('date',),
'classes': ('collapse',)
}),
)
readonly_fields = ('date', 'cost_price')
def reason_display(self, obj):
return obj.get_reason_display()
reason_display.short_description = 'Причина'
# ===== INVENTORY LINE (INLINE) =====
class InventoryLineInline(admin.TabularInline):
model = InventoryLine
extra = 1
fields = ('product', 'quantity_system', 'quantity_fact', 'difference', 'processed')
readonly_fields = ('difference', 'processed')
# ===== INVENTORY =====
@admin.register(Inventory)
class InventoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('warehouse', 'status_display', 'date', 'conducted_by')
list_filter = ('status', 'date', 'warehouse')
search_fields = ('warehouse__name', 'conducted_by')
date_hierarchy = 'date'
fieldsets = (
('Инвентаризация', {
'fields': ('warehouse', 'status', 'conducted_by', 'notes')
}),
('Дата', {
'fields': ('date',),
'classes': ('collapse',)
}),
)
readonly_fields = ('date',)
inlines = [InventoryLineInline]
actions = ['process_inventory']
def status_display(self, obj):
colors = {
'draft': '#ff9900', # оранжевый
'processing': '#0099ff', # синий
'completed': '#008000' # зелёный
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, '#000000'),
obj.get_status_display()
)
status_display.short_description = 'Статус'
def process_inventory(self, request, queryset):
from inventory.services import InventoryProcessor
for inventory in queryset:
result = InventoryProcessor.process_inventory(inventory.id)
msg_parts = [
f"Инвентаризация {inventory.warehouse.name}: "
f"обработано {result['processed_lines']} строк."
]
if result.get('writeoff_document'):
msg_parts.append(
f"Создан документ списания: {result['writeoff_document'].document_number} (черновик)."
)
if result.get('incoming_document'):
msg_parts.append(
f"Создан документ оприходования: {result['incoming_document'].document_number} (черновик)."
)
self.message_user(request, ' '.join(msg_parts))
process_inventory.short_description = 'Обработать инвентаризацию'
# ===== RESERVATION =====
@admin.register(Reservation)
class ReservationAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('product', 'warehouse', 'quantity', 'status_display', 'context_info', 'reserved_at')
list_filter = ('status', 'reserved_at', 'warehouse', 'showcase')
search_fields = ('product__name', 'order_item__order__order_number', 'showcase__name')
date_hierarchy = 'reserved_at'
fieldsets = (
('Резерв', {
'fields': ('product', 'warehouse', 'quantity', 'status', 'order_item', 'showcase')
}),
('Даты', {
'fields': ('reserved_at', 'released_at', 'converted_at')
}),
)
readonly_fields = ('reserved_at', 'released_at', 'converted_at')
def status_display(self, obj):
colors = {
'reserved': '#0099ff', # синий
'released': '#ff0000', # красный
'converted_to_sale': '#008000' # зелёный
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, '#000000'),
obj.get_status_display()
)
status_display.short_description = 'Статус'
def context_info(self, obj):
if obj.order_item:
return format_html(
'<span style="color: #0066cc;">📎 Заказ ORD-{}</span>',
obj.order_item.order.order_number
)
elif obj.showcase:
return format_html(
'<span style="color: #ff9900;">🌺 Витрина: {}</span>',
obj.showcase.name
)
return "-"
context_info.short_description = 'Контекст'
# ===== STOCK =====
@admin.register(Stock)
class StockAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('product', 'warehouse', 'quantity_available', 'quantity_reserved', 'quantity_free', 'updated_at')
list_filter = ('warehouse', 'updated_at')
search_fields = ('product__name', 'product__sku', 'warehouse__name')
fieldsets = (
('Остаток', {
'fields': ('product', 'warehouse', 'quantity_available', 'quantity_reserved')
}),
('Дата', {
'fields': ('updated_at',),
'classes': ('collapse',)
}),
)
readonly_fields = ('quantity_available', 'quantity_reserved', 'updated_at')
# ===== WRITEOFF DOCUMENT (документы списания) =====
class WriteOffDocumentItemInline(admin.TabularInline):
model = WriteOffDocumentItem
extra = 0
fields = ('product', 'quantity', 'reason', 'notes', 'reservation')
readonly_fields = ('reservation',)
raw_id_fields = ('product',)
@admin.register(WriteOffDocument)
class WriteOffDocumentAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('document_number', 'warehouse', 'status_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at')
list_filter = ('status', 'warehouse', 'date', 'created_at')
search_fields = ('document_number', 'warehouse__name')
date_hierarchy = 'date'
readonly_fields = ('document_number', 'created_at', 'updated_at', 'confirmed_at', 'confirmed_by')
inlines = [WriteOffDocumentItemInline]
fieldsets = (
('Документ', {
'fields': ('document_number', 'warehouse', 'status', 'date', 'notes')
}),
('Аудит', {
'fields': ('created_by', 'created_at', 'confirmed_by', 'confirmed_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def status_display(self, obj):
colors = {
'draft': '#ff9900',
'confirmed': '#008000',
'cancelled': '#ff0000',
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, '#000000'),
obj.get_status_display()
)
status_display.short_description = 'Статус'
def items_count(self, obj):
return obj.items.count()
items_count.short_description = 'Позиций'
def total_quantity_display(self, obj):
return f"{obj.total_quantity} шт"
total_quantity_display.short_description = 'Всего'
@admin.register(WriteOffDocumentItem)
class WriteOffDocumentItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('document', 'product', 'quantity', 'reason', 'created_at')
list_filter = ('reason', 'document__status', 'created_at')
search_fields = ('product__name', 'document__document_number')
raw_id_fields = ('product', 'document', 'reservation')
# ===== INCOMING DOCUMENT (документы поступления) =====
class IncomingDocumentItemInline(admin.TabularInline):
model = IncomingDocumentItem
extra = 0
fields = ('product', 'quantity', 'cost_price', 'notes')
raw_id_fields = ('product',)
@admin.register(IncomingDocument)
class IncomingDocumentAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('document_number', 'warehouse', 'status_display', 'receipt_type_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at')
list_filter = ('status', 'warehouse', 'receipt_type', 'date', 'created_at')
search_fields = ('document_number', 'warehouse__name', 'supplier_name')
date_hierarchy = 'date'
readonly_fields = ('document_number', 'created_at', 'updated_at', 'confirmed_at', 'confirmed_by')
inlines = [IncomingDocumentItemInline]
fieldsets = (
('Документ', {
'fields': ('document_number', 'warehouse', 'status', 'date', 'receipt_type', 'supplier_name', 'notes')
}),
('Аудит', {
'fields': ('created_by', 'created_at', 'confirmed_by', 'confirmed_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def status_display(self, obj):
colors = {
'draft': '#ff9900',
'confirmed': '#008000',
'cancelled': '#ff0000',
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, '#000000'),
obj.get_status_display()
)
status_display.short_description = 'Статус'
def receipt_type_display(self, obj):
colors = {
'supplier': '#0d6efd',
'inventory': '#0dcaf0',
'adjustment': '#198754',
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.receipt_type, '#6c757d'),
obj.get_receipt_type_display()
)
receipt_type_display.short_description = 'Тип поступления'
def items_count(self, obj):
return obj.items.count()
items_count.short_description = 'Позиций'
def total_quantity_display(self, obj):
return f"{obj.total_quantity} шт"
total_quantity_display.short_description = 'Всего'
@admin.register(IncomingDocumentItem)
class IncomingDocumentItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ('document', 'product', 'quantity', 'cost_price', 'total_cost_display', 'created_at')
list_filter = ('document__status', 'document__receipt_type', 'created_at')
search_fields = ('product__name', 'document__document_number')
raw_id_fields = ('product', 'document')
def total_cost_display(self, obj):
return f"{obj.total_cost:.2f}"
total_cost_display.short_description = 'Сумма'
# ===== TRANSFORMATION =====
class TransformationInputInline(admin.TabularInline):
model = TransformationInput
extra = 1
fields = ['product', 'quantity']
autocomplete_fields = ['product']
class TransformationOutputInline(admin.TabularInline):
model = TransformationOutput
extra = 1
fields = ['product', 'quantity', 'stock_batch']
autocomplete_fields = ['product']
readonly_fields = ['stock_batch']
@admin.register(Transformation)
class TransformationAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
list_display = ['document_number', 'warehouse', 'status_display', 'date', 'employee', 'inputs_count', 'outputs_count']
list_filter = ['status', 'warehouse', 'date']
search_fields = ['document_number', 'comment']
readonly_fields = ['document_number', 'date', 'created_at', 'updated_at']
inlines = [TransformationInputInline, TransformationOutputInline]
autocomplete_fields = ['warehouse', 'employee']
fieldsets = (
('Основная информация', {
'fields': ('document_number', 'warehouse', 'status', 'employee')
}),
('Детали', {
'fields': ('comment', 'date', 'created_at', 'updated_at')
}),
)
def save_model(self, request, obj, form, change):
if not obj.pk:
# Генерируем номер документа при создании
from inventory.models import DocumentCounter
next_num = DocumentCounter.get_next_value('transformation')
obj.document_number = f"TR-{next_num:05d}"
obj.employee = request.user
super().save_model(request, obj, form, change)
def status_display(self, obj):
colors = {
'draft': '#6c757d',
'completed': '#28a745',
'cancelled': '#dc3545',
}
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
colors.get(obj.status, '#6c757d'),
obj.get_status_display()
)
status_display.short_description = 'Статус'
def inputs_count(self, obj):
return obj.inputs.count()
inputs_count.short_description = 'Входов'
def outputs_count(self, obj):
return obj.outputs.count()
outputs_count.short_description = 'Выходов'