Files
octopus/myproject/inventory/admin.py
Andrey Smakotin a8ba5ce780 Улучшения инвентаризации: автоматическое проведение документов, оптимизация запросов и улучшения UI
- Автоматическое проведение документов списания и оприходования после завершения инвентаризации
- Оптимизация SQL-запросов: устранение N+1, bulk-операции для Stock, агрегация для StockBatch и Reservation
- Изменение формулы расчета разницы: (quantity_fact + quantity_reserved) - quantity_available
- Переименование поля 'По факту' в 'Подсчитано (факт, свободные)'
- Добавлены столбцы 'В резервах' и 'Всего на складе' в таблицу инвентаризации
- Перемещение столбца 'В системе (свободно)' после 'В резервах' с визуальным выделением
- Центральное выравнивание значений в столбцах таблицы
- Автоматическое выделение текста при фокусе на поле ввода количества
- Исправление форматирования разницы (убраны лишние нули)
- Изменение статуса 'Не обработана' на 'Не проведено'
- Добавление номера документа для инвентаризаций (INV-XXXXXX)
- Отображение всех типов списаний в debug-странице (WriteOff, WriteOffDocument, WriteOffDocumentItem)
- Улучшение отображения документов в детальном просмотре инвентаризации с возможностью перехода к ним
2025-12-21 23:59:02 +03:00

515 lines
18 KiB
Python
Raw 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.
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, Incoming, IncomingBatch, Sale, WriteOff, Transfer,
Inventory, InventoryLine, Reservation, Stock, StockMovement,
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
IncomingDocument, IncomingDocumentItem
)
# ===== SHOWCASE =====
@admin.register(Showcase)
class ShowcaseAdmin(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')
# ===== WAREHOUSE =====
@admin.register(Warehouse)
class WarehouseAdmin(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 = 'По умолчанию'
# ===== STOCK BATCH =====
@admin.register(StockBatch)
class StockBatchAdmin(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 = 'Количество'
# ===== INCOMING BATCH =====
@admin.register(IncomingBatch)
class IncomingBatchAdmin(admin.ModelAdmin):
list_display = ('document_number', 'warehouse', 'receipt_type_display', 'supplier_name', 'items_count', 'created_at')
list_filter = ('warehouse', 'receipt_type', 'created_at')
search_fields = ('document_number', 'supplier_name')
date_hierarchy = 'created_at'
fieldsets = (
('Партия поступления', {
'fields': ('document_number', 'warehouse', 'receipt_type', 'supplier_name', 'notes')
}),
('Даты', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
def items_count(self, obj):
return obj.items.count()
items_count.short_description = 'Товаров'
def receipt_type_display(self, obj):
colors = {
'supplier': '#0d6efd', # primary (синий)
'inventory': '#0dcaf0', # info (голубой)
'adjustment': '#198754', # success (зеленый)
}
color = colors.get(obj.receipt_type, '#6c757d')
return format_html(
'<span style="color: {}; font-weight: bold;">{}</span>',
color,
obj.get_receipt_type_display()
)
receipt_type_display.short_description = 'Тип поступления'
# ===== INCOMING =====
@admin.register(Incoming)
class IncomingAdmin(admin.ModelAdmin):
list_display = ('product', 'batch', 'quantity', 'cost_price', 'created_at')
list_filter = ('batch__warehouse', 'created_at', 'product')
search_fields = ('product__name', 'batch__document_number')
date_hierarchy = 'created_at'
fieldsets = (
('Товар в партии', {
'fields': ('batch', 'product', 'quantity', 'cost_price', 'stock_batch')
}),
('Дата', {
'fields': ('created_at',),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'stock_batch')
# ===== 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(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(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 = 'Причина'
# ===== TRANSFER =====
@admin.register(Transfer)
class TransferAdmin(admin.ModelAdmin):
list_display = ('batch', 'from_warehouse', 'to_warehouse', 'quantity', 'date')
list_filter = ('date', 'from_warehouse', 'to_warehouse')
search_fields = ('batch__product__name', 'document_number')
date_hierarchy = 'date'
fieldsets = (
('Перемещение', {
'fields': ('batch', 'from_warehouse', 'to_warehouse', 'quantity', 'new_batch')
}),
('Документ', {
'fields': ('document_number',)
}),
('Дата', {
'fields': ('date',),
'classes': ('collapse',)
}),
)
readonly_fields = ('date', 'new_batch')
# ===== 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(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(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(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')
# ===== STOCK MOVEMENT (для аудита) =====
@admin.register(StockMovement)
class StockMovementAdmin(admin.ModelAdmin):
list_display = ('product', 'change', 'reason', 'order', 'created_at')
list_filter = ('reason', 'created_at')
search_fields = ('product__name', 'order__order_number')
date_hierarchy = 'created_at'
readonly_fields = ('created_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(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(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(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(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 = 'Сумма'