Добавлено разделение типов поступлений на склад
- Добавлено поле receipt_type в модель IncomingBatch с типами: supplier, inventory, adjustment - Исправлен баг в InventoryProcessor: теперь корректно создается IncomingBatch при инвентаризации - Создан IncomingAdjustmentCreateView для оприходования без инвентаризации - Обновлены формы, шаблоны и админка для поддержки разных типов поступлений - Добавлена навигация и URL для оприходования - Тип поступления отображается в списках приходов и партий
This commit is contained in:
@@ -94,13 +94,13 @@ class StockBatchAdmin(admin.ModelAdmin):
|
||||
# ===== 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')
|
||||
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', 'supplier_name', 'notes')
|
||||
'fields': ('document_number', 'warehouse', 'receipt_type', 'supplier_name', 'notes')
|
||||
}),
|
||||
('Даты', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
@@ -113,6 +113,20 @@ class IncomingBatchAdmin(admin.ModelAdmin):
|
||||
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)
|
||||
|
||||
@@ -272,6 +272,13 @@ class IncomingForm(forms.Form):
|
||||
required=False
|
||||
)
|
||||
|
||||
receipt_type = forms.CharField(
|
||||
max_length=20,
|
||||
widget=forms.HiddenInput(),
|
||||
initial='supplier',
|
||||
required=False
|
||||
)
|
||||
|
||||
supplier_name = forms.CharField(
|
||||
max_length=200,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ООО Поставщик'}),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.0.10 on 2025-12-20 20:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('inventory', '0012_change_sold_order_item_to_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='incomingbatch',
|
||||
name='receipt_type',
|
||||
field=models.CharField(choices=[('supplier', 'Поступление от поставщика'), ('inventory', 'Оприходование при инвентаризации'), ('adjustment', 'Оприходование без инвентаризации')], db_index=True, default='supplier', max_length=20, verbose_name='Тип поступления'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='incomingbatch',
|
||||
index=models.Index(fields=['receipt_type'], name='inventory_i_receipt_ce70c1_idx'),
|
||||
),
|
||||
]
|
||||
@@ -105,10 +105,23 @@ class IncomingBatch(models.Model):
|
||||
Партия поступления товара (один номер документа = одна партия).
|
||||
Содержит один номер документа и может включать несколько товаров.
|
||||
"""
|
||||
RECEIPT_TYPE_CHOICES = [
|
||||
('supplier', 'Поступление от поставщика'),
|
||||
('inventory', 'Оприходование при инвентаризации'),
|
||||
('adjustment', 'Оприходование без инвентаризации'),
|
||||
]
|
||||
|
||||
warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE,
|
||||
related_name='incoming_batches', verbose_name="Склад")
|
||||
document_number = models.CharField(max_length=100, unique=True, db_index=True,
|
||||
verbose_name="Номер документа")
|
||||
receipt_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=RECEIPT_TYPE_CHOICES,
|
||||
default='supplier',
|
||||
db_index=True,
|
||||
verbose_name="Тип поступления"
|
||||
)
|
||||
supplier_name = models.CharField(max_length=200, blank=True, null=True,
|
||||
verbose_name="Наименование поставщика")
|
||||
notes = models.TextField(blank=True, null=True, verbose_name="Примечания")
|
||||
@@ -122,6 +135,7 @@ class IncomingBatch(models.Model):
|
||||
indexes = [
|
||||
models.Index(fields=['document_number']),
|
||||
models.Index(fields=['warehouse']),
|
||||
models.Index(fields=['receipt_type']),
|
||||
models.Index(fields=['-created_at']),
|
||||
]
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from inventory.models import (
|
||||
Inventory, InventoryLine, WriteOff, Incoming,
|
||||
Inventory, InventoryLine, WriteOff, Incoming, IncomingBatch,
|
||||
StockBatch, Stock
|
||||
)
|
||||
from inventory.services.batch_manager import StockBatchManager
|
||||
from inventory.utils import generate_incoming_document_number
|
||||
|
||||
|
||||
class InventoryProcessor:
|
||||
@@ -142,21 +143,24 @@ class InventoryProcessor:
|
||||
inventory.warehouse
|
||||
)
|
||||
|
||||
# Создаем новую партию
|
||||
batch = StockBatchManager.create_batch(
|
||||
line.product,
|
||||
inventory.warehouse,
|
||||
quantity_surplus,
|
||||
cost_price
|
||||
# Генерируем номер документа для поступления
|
||||
document_number = generate_incoming_document_number()
|
||||
|
||||
# Создаем IncomingBatch с типом 'inventory'
|
||||
incoming_batch = IncomingBatch.objects.create(
|
||||
warehouse=inventory.warehouse,
|
||||
document_number=document_number,
|
||||
receipt_type='inventory',
|
||||
notes=f'Оприходование при инвентаризации {inventory.id}, строка {line.id}'
|
||||
)
|
||||
|
||||
# Создаем документ Incoming
|
||||
# Сигнал create_stock_batch_on_incoming автоматически создаст StockBatch
|
||||
Incoming.objects.create(
|
||||
batch=incoming_batch,
|
||||
product=line.product,
|
||||
warehouse=inventory.warehouse,
|
||||
quantity=quantity_surplus,
|
||||
cost_price=cost_price,
|
||||
batch=batch,
|
||||
notes=f'Инвентаризация {inventory.id}, строка {line.id}'
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:warehouse-list' %}">Склады</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:incoming-list' %}">Приходы</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:incoming-create' %}">Поступление товара</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:incoming-adjustment-create' %}">Оприходование</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:sale-list' %}">Продажи</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:inventory-list' %}">Инвентаризация</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'inventory:writeoff-list' %}">Списания</a></li>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends 'inventory/base_inventory_minimal.html' %}
|
||||
{% load inventory_filters %}
|
||||
{% block inventory_title %}Массовое поступление товара{% endblock %}
|
||||
{% block inventory_title %}{% if is_adjustment %}Оприходование товара{% else %}Массовое поступление товара{% endif %}{% endblock %}
|
||||
{% block breadcrumb_current %}Приходы{% endblock %}
|
||||
{% block inventory_content %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">Поступление товара от поставщика</h4>
|
||||
<h4 class="mb-0">{% if is_adjustment %}Оприходование товара{% else %}Поступление товара от поставщика{% endif %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Ошибки общей формы -->
|
||||
@@ -48,6 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not is_adjustment %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ form.supplier_name.label }}</label>
|
||||
{{ form.supplier_name }}
|
||||
@@ -55,6 +56,9 @@
|
||||
<div class="text-danger small">{{ form.supplier_name.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ form.receipt_type }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ form.notes.label }}</label>
|
||||
@@ -120,7 +124,7 @@
|
||||
<!-- ============== КНОПКИ ============== -->
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="bi bi-check-circle"></i> Создать поступление
|
||||
<i class="bi bi-check-circle"></i> {% if is_adjustment %}Создать оприходование{% else %}Создать поступление{% endif %}
|
||||
</button>
|
||||
<a href="{% url 'inventory:incoming-list' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<tr>
|
||||
<th>Товар</th>
|
||||
<th>Склад</th>
|
||||
<th>Тип</th>
|
||||
<th>Количество</th>
|
||||
<th>Цена закупки</th>
|
||||
<th>Номер документа</th>
|
||||
@@ -34,6 +35,11 @@
|
||||
<tr>
|
||||
<td><strong>{{ incoming.product.name }}</strong></td>
|
||||
<td>{{ incoming.batch.warehouse.name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if incoming.batch.receipt_type == 'supplier' %}primary{% elif incoming.batch.receipt_type == 'inventory' %}info{% else %}success{% endif %}">
|
||||
{{ incoming.batch.get_receipt_type_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ incoming.quantity|smart_quantity }} шт</td>
|
||||
<td>{{ incoming.cost_price }} руб.</td>
|
||||
<td>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<tr>
|
||||
<th>Номер документа</th>
|
||||
<th>Склад</th>
|
||||
<th>Тип</th>
|
||||
<th>Поставщик</th>
|
||||
<th>Товары</th>
|
||||
<th>Кол-во</th>
|
||||
@@ -31,6 +32,11 @@
|
||||
<tr>
|
||||
<td><strong>{{ batch.document_number }}</strong></td>
|
||||
<td>{{ batch.warehouse.name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if batch.receipt_type == 'supplier' %}primary{% elif batch.receipt_type == 'inventory' %}info{% else %}success{% endif %}">
|
||||
{{ batch.get_receipt_type_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ batch.supplier_name|default:"—" }}</td>
|
||||
<td>
|
||||
<small>
|
||||
|
||||
@@ -4,7 +4,7 @@ from .views import (
|
||||
# Warehouse
|
||||
WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView,
|
||||
# Incoming
|
||||
IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView,
|
||||
IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView,
|
||||
# IncomingBatch
|
||||
IncomingBatchListView, IncomingBatchDetailView,
|
||||
# Sale
|
||||
@@ -54,6 +54,7 @@ urlpatterns = [
|
||||
# ==================== INCOMING ====================
|
||||
path('incoming/', IncomingListView.as_view(), name='incoming-list'),
|
||||
path('incoming/create/', IncomingCreateView.as_view(), name='incoming-create'),
|
||||
path('incoming/adjustment/create/', IncomingAdjustmentCreateView.as_view(), name='incoming-adjustment-create'),
|
||||
path('incoming/<int:pk>/edit/', IncomingUpdateView.as_view(), name='incoming-update'),
|
||||
path('incoming/<int:pk>/delete/', IncomingDeleteView.as_view(), name='incoming-delete'),
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
from .warehouse import WarehouseListView, WarehouseCreateView, WarehouseUpdateView, WarehouseDeleteView, SetDefaultWarehouseView
|
||||
from .incoming import IncomingListView, IncomingCreateView, IncomingUpdateView, IncomingDeleteView
|
||||
from .incoming import IncomingListView, IncomingCreateView, IncomingAdjustmentCreateView, IncomingUpdateView, IncomingDeleteView
|
||||
from .batch import IncomingBatchListView, IncomingBatchDetailView, StockBatchListView, StockBatchDetailView
|
||||
from .sale import SaleListView, SaleCreateView, SaleUpdateView, SaleDeleteView, SaleDetailView
|
||||
from .inventory_ops import (
|
||||
@@ -48,7 +48,7 @@ __all__ = [
|
||||
# Warehouse
|
||||
'WarehouseListView', 'WarehouseCreateView', 'WarehouseUpdateView', 'WarehouseDeleteView', 'SetDefaultWarehouseView',
|
||||
# Incoming
|
||||
'IncomingListView', 'IncomingCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
|
||||
'IncomingListView', 'IncomingCreateView', 'IncomingAdjustmentCreateView', 'IncomingUpdateView', 'IncomingDeleteView',
|
||||
# IncomingBatch
|
||||
'IncomingBatchListView', 'IncomingBatchDetailView',
|
||||
# Sale
|
||||
|
||||
@@ -119,6 +119,7 @@ class IncomingCreateView(LoginRequiredMixin, View):
|
||||
# Оставляем пустой document_number как есть - модель будет генерировать при сохранении
|
||||
document_number = form.cleaned_data.get('document_number', '').strip() or None
|
||||
|
||||
receipt_type = form.cleaned_data.get('receipt_type', 'supplier')
|
||||
supplier_name = form.cleaned_data.get('supplier_name', '')
|
||||
header_notes = form.cleaned_data.get('notes', '')
|
||||
|
||||
@@ -148,7 +149,8 @@ class IncomingCreateView(LoginRequiredMixin, View):
|
||||
batch = IncomingBatch.objects.create(
|
||||
warehouse=warehouse,
|
||||
document_number=document_number,
|
||||
supplier_name=supplier_name,
|
||||
receipt_type=receipt_type,
|
||||
supplier_name=supplier_name if receipt_type == 'supplier' else '',
|
||||
notes=header_notes
|
||||
)
|
||||
file_logger.info(f" ✓ Created batch: {document_number}")
|
||||
@@ -208,7 +210,8 @@ class IncomingCreateView(LoginRequiredMixin, View):
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
def _parse_products_from_post(self, post_data):
|
||||
@staticmethod
|
||||
def _parse_products_from_post(post_data):
|
||||
"""
|
||||
Парсит данные товаров из POST данных.
|
||||
Ожидается formato:
|
||||
@@ -235,3 +238,139 @@ class IncomingCreateView(LoginRequiredMixin, View):
|
||||
pass
|
||||
|
||||
return products_data
|
||||
|
||||
|
||||
class IncomingAdjustmentCreateView(LoginRequiredMixin, View):
|
||||
"""
|
||||
Создание оприходования товара на склад (без инвентаризации).
|
||||
Аналогично IncomingCreateView, но с типом 'adjustment' и без поля поставщика.
|
||||
"""
|
||||
template_name = 'inventory/incoming/incoming_bulk_form.html'
|
||||
|
||||
def get(self, request):
|
||||
"""Отображение формы ввода товаров."""
|
||||
form = IncomingForm(initial={'receipt_type': 'adjustment'})
|
||||
# Django-tenants автоматически фильтрует по текущей схеме
|
||||
products = Product.objects.filter(status='active').order_by('name')
|
||||
|
||||
# Генерируем номер документа автоматически
|
||||
generated_document_number = generate_incoming_document_number()
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
'products': products,
|
||||
'generated_document_number': generated_document_number,
|
||||
'is_adjustment': True, # Флаг для шаблона, чтобы скрыть supplier_name
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
def post(self, request):
|
||||
"""Обработка формы ввода товаров."""
|
||||
# Устанавливаем receipt_type в 'adjustment'
|
||||
post_data = request.POST.copy()
|
||||
post_data['receipt_type'] = 'adjustment'
|
||||
form = IncomingForm(post_data)
|
||||
|
||||
if not form.is_valid():
|
||||
# Django-tenants автоматически фильтрует по текущей схеме
|
||||
products = Product.objects.filter(status='active').order_by('name')
|
||||
context = {
|
||||
'form': form,
|
||||
'products': products,
|
||||
'errors': form.errors,
|
||||
'is_adjustment': True,
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
# Получаем данные header
|
||||
warehouse = form.cleaned_data['warehouse']
|
||||
document_number = form.cleaned_data.get('document_number', '').strip() or None
|
||||
|
||||
receipt_type = 'adjustment' # Всегда adjustment для этого view
|
||||
header_notes = form.cleaned_data.get('notes', '')
|
||||
|
||||
# Получаем данные товаров из POST
|
||||
products_data = IncomingCreateView._parse_products_from_post(request.POST)
|
||||
|
||||
if not products_data:
|
||||
messages.error(request, 'Пожалуйста, добавьте хотя бы один товар.')
|
||||
products = Product.objects.filter(status='active').order_by('name')
|
||||
context = {
|
||||
'form': form,
|
||||
'products': products,
|
||||
'is_adjustment': True,
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
# Генерируем номер партии один раз (если не указан)
|
||||
if not document_number:
|
||||
document_number = generate_incoming_document_number()
|
||||
|
||||
file_logger.info(f"--- POST started (adjustment) | batch_doc_number={document_number} | items_count={len(products_data)}")
|
||||
|
||||
try:
|
||||
# Используем транзакцию для атомарности: либо все товары, либо ничего
|
||||
with transaction.atomic():
|
||||
# 1. Создаем партию (содержит номер документа и метаданные)
|
||||
batch = IncomingBatch.objects.create(
|
||||
warehouse=warehouse,
|
||||
document_number=document_number,
|
||||
receipt_type=receipt_type,
|
||||
supplier_name='', # Не заполняем для adjustment
|
||||
notes=header_notes
|
||||
)
|
||||
file_logger.info(f" ✓ Created batch: {document_number}")
|
||||
|
||||
# 2. Создаем товары в этой партии
|
||||
created_count = 0
|
||||
for product_data in products_data:
|
||||
incoming = Incoming.objects.create(
|
||||
batch=batch,
|
||||
product_id=product_data['product_id'],
|
||||
quantity=product_data['quantity'],
|
||||
cost_price=product_data['cost_price'],
|
||||
)
|
||||
created_count += 1
|
||||
file_logger.info(f" ✓ Item: {incoming.product.name} ({incoming.quantity} шт)")
|
||||
|
||||
file_logger.info(f"✓ SUCCESS: Batch {document_number} with {created_count} items")
|
||||
messages.success(
|
||||
request,
|
||||
f'✓ Успешно создано оприходование "{document_number}" с {created_count} товарами.'
|
||||
)
|
||||
return redirect('inventory:incoming-list')
|
||||
|
||||
except IntegrityError as e:
|
||||
file_logger.error(f"✗ IntegrityError: {str(e)} | doc_number={document_number}")
|
||||
if 'document_number' in str(e):
|
||||
error_msg = (
|
||||
f'❌ Номер документа "{document_number}" уже существует в системе. '
|
||||
f'Пожалуйста, оставьте поле пустым для автогенерации свободного номера. '
|
||||
f'Данные, которые вы вводили, сохранены ниже.'
|
||||
)
|
||||
messages.error(request, error_msg)
|
||||
else:
|
||||
messages.error(request, f'Ошибка при создании оприходования: {str(e)}')
|
||||
|
||||
products = Product.objects.filter(status='active').order_by('name')
|
||||
context = {
|
||||
'form': form,
|
||||
'products': products,
|
||||
'products_json': request.POST.get('products_json', '[]'),
|
||||
'is_adjustment': True,
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
request,
|
||||
f'❌ Ошибка при создании оприходования: {str(e)}'
|
||||
)
|
||||
products = Product.objects.filter(status='active').order_by('name')
|
||||
context = {
|
||||
'form': form,
|
||||
'products': products,
|
||||
'products_json': request.POST.get('products_json', '[]'),
|
||||
'is_adjustment': True,
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
Reference in New Issue
Block a user