feat: Реализовать систему поступления товаров с партиями (IncomingBatch)
Основные изменения: - Создана модель 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>
This commit is contained in:
@@ -1,19 +1,325 @@
|
||||
from django.contrib import admin
|
||||
from .models import Stock, StockMovement
|
||||
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', 'quantity_available', 'quantity_reserved', 'updated_at')
|
||||
list_filter = ('updated_at',)
|
||||
search_fields = ('product__name', 'product__sku')
|
||||
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__id')
|
||||
search_fields = ('product__name', 'order__order_number')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
|
||||
admin.site.register(Stock, StockAdmin)
|
||||
admin.site.register(StockMovement, StockMovementAdmin)
|
||||
readonly_fields = ('created_at',)
|
||||
|
||||
Reference in New Issue
Block a user