Приведение к единому паттерну именования документов: - TransferBatch → TransferDocument - TransferItem → TransferDocumentItem - Удалена устаревшая модель Transfer (одиночные перемещения) - Удалена неиспользуемая модель StockMovement Изменения: - models.py: переименование классов, обновление related_names - admin.py: удаление регистраций Transfer/StockMovement - forms.py: обновление TransferHeaderForm - views/transfer.py: обновление всех view классов - templates: замена transfer_batch → transfer_document - urls.py: удаление путей для movements - views/__init__.py: удаление импорта StockMovementListView - views/movements.py: удален файл Миграция: 0005_refactor_transfer_models - RenameModel операции для сохранения данных - DeleteModel для Transfer и StockMovement Единый паттерн: *Document + *DocumentItem (WriteOffDocument, IncomingDocument, TransferDocument) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
494 lines
18 KiB
Python
494 lines
18 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, Sale, WriteOff,
|
||
Inventory, InventoryLine, Reservation, Stock,
|
||
SaleBatchAllocation, Showcase, WriteOffDocument, WriteOffDocumentItem,
|
||
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
|
||
TransformationOutput, TransferDocument, TransferDocumentItem
|
||
)
|
||
|
||
|
||
# ===== 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 = 'Количество'
|
||
|
||
|
||
# ===== 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 = 'Причина'
|
||
|
||
|
||
# ===== 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')
|
||
|
||
|
||
# ===== 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 = 'Сумма'
|
||
|
||
|
||
# ===== 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(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(
|
||
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||
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 = 'Выходов'
|