Основные изменения: - Создана модель IncomingBatch для группировки товаров по документам - Каждое поступление (Incoming) связано с одной батчем поступления - Автоматическое создание StockBatch для каждого товара в приходе - Реализована система нумерации партий (IN-XXXX-XXXX) с поиском максимума в БД - Обновлены все представления (views) для работы с новой архитектурой - Добавлены детальные страницы просмотра партий поступлений - Обновлены шаблоны для отображения информации о партиях и их товарах - Исправлена логика сигналов для создания StockBatch при приходе товара - Обновлены формы для работы с новой структурой IncomingBatch Архитектура FIFO: - IncomingBatch: одна партия поступления (номер IN-XXXX-XXXX) - Incoming: товар в партии поступления - StockBatch: одна партия товара на складе (создается для каждого товара) Это позволяет системе правильно применять FIFO при продаже товаров. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
326 lines
11 KiB
Python
326 lines
11 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
|
|
)
|
|
|
|
|
|
# ===== WAREHOUSE =====
|
|
@admin.register(Warehouse)
|
|
class WarehouseAdmin(admin.ModelAdmin):
|
|
list_display = ('name', 'is_active', 'created_at')
|
|
list_filter = ('is_active', 'created_at')
|
|
search_fields = ('name',)
|
|
fieldsets = (
|
|
('Основная информация', {
|
|
'fields': ('name', 'description', 'is_active')
|
|
}),
|
|
('Даты', {
|
|
'fields': ('created_at', 'updated_at'),
|
|
'classes': ('collapse',)
|
|
}),
|
|
)
|
|
readonly_fields = ('created_at', 'updated_at')
|
|
|
|
|
|
# ===== 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', 'supplier_name', 'items_count', 'created_at')
|
|
list_filter = ('warehouse', 'created_at')
|
|
search_fields = ('document_number', 'supplier_name')
|
|
date_hierarchy = 'created_at'
|
|
fieldsets = (
|
|
('Партия поступления', {
|
|
'fields': ('document_number', 'warehouse', '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 = 'Товаров'
|
|
|
|
|
|
# ===== 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', 'order_info', 'reserved_at')
|
|
list_filter = ('status', 'reserved_at', 'warehouse')
|
|
search_fields = ('product__name', 'order_item__order__order_number')
|
|
date_hierarchy = 'reserved_at'
|
|
fieldsets = (
|
|
('Резерв', {
|
|
'fields': ('product', 'warehouse', 'quantity', 'status', 'order_item')
|
|
}),
|
|
('Даты', {
|
|
'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 order_info(self, obj):
|
|
if obj.order_item:
|
|
return f"ORD-{obj.order_item.order.order_number}"
|
|
return "-"
|
|
order_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',)
|