- Добавлено поле receipt_type в модель IncomingBatch с типами: supplier, inventory, adjustment - Исправлен баг в InventoryProcessor: теперь корректно создается IncomingBatch при инвентаризации - Создан IncomingAdjustmentCreateView для оприходования без инвентаризации - Обновлены формы, шаблоны и админка для поддержки разных типов поступлений - Добавлена навигация и URL для оприходования - Тип поступления отображается в списках приходов и партий
431 lines
15 KiB
Python
431 lines
15 KiB
Python
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
|
||
)
|
||
|
||
|
||
# ===== 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)
|
||
self.message_user(
|
||
request,
|
||
f"Инвентаризация {inventory.warehouse.name}: "
|
||
f"обработано {result['processed_lines']} строк, "
|
||
f"создано {result['writeoffs_created']} списаний и "
|
||
f"{result['incomings_created']} приходов"
|
||
)
|
||
|
||
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')
|