""" Админка для приложения 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('★ По умолчанию') 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( '{}', 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('✓ Обработана') return format_html('✗ Ожидает') 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( '{}', 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( '{}', 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( '📎 Заказ ORD-{}', obj.order_item.order.order_number ) elif obj.showcase: return format_html( '🌺 Витрина: {}', 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( '{}', 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( '{}', 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( '{}', 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( '{}', 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 = 'Выходов'