This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
from django.contrib import admin
|
||||
from .models import MaterialCategory, SteelGrade, Material
|
||||
from django.contrib import admin, messages
|
||||
from django.utils import timezone
|
||||
|
||||
from warehouse.services.transfers import receive_transfer
|
||||
|
||||
from .models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord
|
||||
|
||||
@admin.register(MaterialCategory)
|
||||
class MaterialCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'gost_standard')
|
||||
list_display = ('name', 'form_factor', 'gost_standard')
|
||||
list_filter = ('form_factor',)
|
||||
search_fields = ('name', 'gost_standard')
|
||||
|
||||
@admin.register(SteelGrade)
|
||||
@@ -17,3 +22,103 @@ class MaterialAdmin(admin.ModelAdmin):
|
||||
list_filter = ('category', 'steel_grade')
|
||||
search_fields = ('name', 'full_name')
|
||||
readonly_fields = ('full_name',)
|
||||
|
||||
|
||||
@admin.register(Location)
|
||||
class LocationAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'is_production_area')
|
||||
list_filter = ('is_production_area',)
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
@admin.register(StockItem)
|
||||
class StockItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('location', 'material', 'entity', 'quantity', 'is_remnant', 'unique_id')
|
||||
list_filter = ('location', 'is_remnant', 'material__category')
|
||||
search_fields = ('material__name', 'material__full_name', 'entity__name', 'entity__drawing_number', 'unique_id')
|
||||
autocomplete_fields = ('location', 'material', 'entity')
|
||||
|
||||
|
||||
class TransferLineInline(admin.TabularInline):
|
||||
model = TransferLine
|
||||
fk_name = 'transfer'
|
||||
fields = ('stock_item', 'quantity')
|
||||
autocomplete_fields = ('stock_item',)
|
||||
extra = 5
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name == 'stock_item':
|
||||
from_location_id = None
|
||||
|
||||
tr = getattr(request, '_transfer_obj', None)
|
||||
if tr and getattr(tr, 'from_location_id', None):
|
||||
from_location_id = tr.from_location_id
|
||||
|
||||
if not from_location_id and request.method == 'POST':
|
||||
raw = (request.POST.get('from_location') or '').strip()
|
||||
if raw.isdigit():
|
||||
from_location_id = int(raw)
|
||||
|
||||
if from_location_id:
|
||||
kwargs['queryset'] = StockItem.objects.filter(location_id=from_location_id)
|
||||
else:
|
||||
kwargs['queryset'] = StockItem.objects.all()
|
||||
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
|
||||
@admin.register(TransferRecord)
|
||||
class TransferRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ('from_location', 'to_location', 'id', 'occurred_at', 'sender', 'receiver', 'status', 'is_applied')
|
||||
list_display_links = ('from_location',)
|
||||
list_filter = ('status', 'from_location', 'to_location')
|
||||
search_fields = ('sender__username', 'receiver__username')
|
||||
autocomplete_fields = ('from_location', 'to_location', 'sender', 'receiver')
|
||||
inlines = (TransferLineInline,)
|
||||
actions = ('action_receive',)
|
||||
|
||||
def get_changeform_initial_data(self, request):
|
||||
initial = super().get_changeform_initial_data(request)
|
||||
initial.setdefault('sender', request.user.id)
|
||||
initial.setdefault('occurred_at', timezone.now())
|
||||
return initial
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
request._transfer_obj = obj
|
||||
return super().get_form(request, obj, **kwargs)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not obj.sender_id:
|
||||
obj.sender = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def save_related(self, request, form, formsets, change):
|
||||
super().save_related(request, form, formsets, change)
|
||||
|
||||
obj = form.instance
|
||||
|
||||
# Применяем перемещение автоматически после сохранения строк.
|
||||
# Если строк нет — receive_transfer выбросит понятную ошибку.
|
||||
if not getattr(obj, 'is_applied', False):
|
||||
try:
|
||||
receive_transfer(obj.id, request.user.id)
|
||||
except Exception as e:
|
||||
self.message_user(request, f'Перемещение id={obj.id}: {e}', level=messages.ERROR)
|
||||
|
||||
@admin.action(description='Принять перемещение')
|
||||
def action_receive(self, request, queryset):
|
||||
ok = 0
|
||||
failed = 0
|
||||
|
||||
for tr in queryset:
|
||||
try:
|
||||
receive_transfer(tr.id, request.user.id)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
self.message_user(request, f'Перемещение id={tr.id}: {e}', level=messages.ERROR)
|
||||
|
||||
if ok:
|
||||
self.message_user(request, f'Применено: {ok}.', level=messages.SUCCESS)
|
||||
if failed:
|
||||
self.message_user(request, f'Ошибок: {failed}.', level=messages.ERROR)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-04 15:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('manufacturing', '0001_initial'),
|
||||
('warehouse', '0003_alter_material_full_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Location',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Место хранения')),
|
||||
('is_production_area', models.BooleanField(default=False, verbose_name='Это производственный участок')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Склад/Участок',
|
||||
'verbose_name_plural': 'Склады и участки',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.FloatField(verbose_name='Количество (шт/м/кг/лист)')),
|
||||
('is_remnant', models.BooleanField(default=False, verbose_name='Деловой остаток')),
|
||||
('current_length', models.FloatField(blank=True, null=True, verbose_name='Текущая длина, мм')),
|
||||
('current_width', models.FloatField(blank=True, null=True, verbose_name='Текущая ширина, мм')),
|
||||
('unique_id', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='ID/Маркировка (для ДО)')),
|
||||
('entity', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Произведённая сущность')),
|
||||
('location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Где находится')),
|
||||
('material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Сырьё')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Единица на складе',
|
||||
'verbose_name_plural': 'Остатки на складах',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TransferRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('sent', 'В пути'), ('received', 'Принято'), ('discrepancy', 'Расхождение')], default='sent', max_length=20, verbose_name='Статус')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('received_at', models.DateTimeField(blank=True, null=True)),
|
||||
('from_location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='warehouse.location')),
|
||||
('items', models.ManyToManyField(to='warehouse.stockitem', verbose_name='Перемещаемые объекты')),
|
||||
('receiver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='received_transfers', to=settings.AUTH_USER_MODEL)),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sent_transfers', to=settings.AUTH_USER_MODEL)),
|
||||
('to_location', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming', to='warehouse.location')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Перемещение',
|
||||
'verbose_name_plural': 'Перемещения',
|
||||
},
|
||||
),
|
||||
]
|
||||
17
warehouse/migrations/0005_alter_stockitem_options.py
Normal file
17
warehouse/migrations/0005_alter_stockitem_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 08:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0004_location_stockitem_transferrecord'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='stockitem',
|
||||
options={'verbose_name': 'Единица на складах', 'verbose_name_plural': 'Единицы на складах'},
|
||||
),
|
||||
]
|
||||
17
warehouse/migrations/0006_alter_stockitem_options.py
Normal file
17
warehouse/migrations/0006_alter_stockitem_options.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 08:50
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0005_alter_stockitem_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='stockitem',
|
||||
options={'verbose_name': 'Единица на складе', 'verbose_name_plural': 'Единицы на складе'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 11:42
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0006_alter_stockitem_options'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='transferrecord',
|
||||
name='items',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transferrecord',
|
||||
name='is_applied',
|
||||
field=models.BooleanField(default=False, verbose_name='Применено'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transferrecord',
|
||||
name='occurred_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата/время'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='from_location',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing', to='warehouse.location', verbose_name='Откуда'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='receiver',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='received_transfers', to=settings.AUTH_USER_MODEL, verbose_name='Кому'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='sender',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sent_transfers', to=settings.AUTH_USER_MODEL, verbose_name='Кто'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='to_location',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming', to='warehouse.location', verbose_name='Куда'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TransferLine',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.FloatField(verbose_name='Количество')),
|
||||
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Единица на складе')),
|
||||
('transfer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='warehouse.transferrecord', verbose_name='Перемещение')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Строка перемещения',
|
||||
'verbose_name_plural': 'Строки перемещения',
|
||||
'unique_together': {('transfer', 'stock_item')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 15:45
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0007_remove_transferrecord_items_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='received_at',
|
||||
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transferrecord',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('sent', 'В пути'), ('received', 'Принято'), ('discrepancy', 'Расхождение')], default='received', max_length=20, verbose_name='Статус'),
|
||||
),
|
||||
]
|
||||
19
warehouse/migrations/0009_stockitem_created_at.py
Normal file
19
warehouse/migrations/0009_stockitem_created_at.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 19:06
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0008_alter_transferrecord_received_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Поступление'),
|
||||
),
|
||||
]
|
||||
18
warehouse/migrations/0010_materialcategory_form_factor.py
Normal file
18
warehouse/migrations/0010_materialcategory_form_factor.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 19:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0009_stockitem_created_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='materialcategory',
|
||||
name='form_factor',
|
||||
field=models.CharField(choices=[('sheet', 'Лист'), ('bar', 'Прокат/хлыст'), ('other', 'Прочее')], default='other', max_length=16, verbose_name='Форма'),
|
||||
),
|
||||
]
|
||||
18
warehouse/migrations/0011_stockitem_is_customer_supplied.py
Normal file
18
warehouse/migrations/0011_stockitem_is_customer_supplied.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-05 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('warehouse', '0010_materialcategory_form_factor'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='is_customer_supplied',
|
||||
field=models.BooleanField(default=False, verbose_name='Давальческий'),
|
||||
),
|
||||
]
|
||||
20
warehouse/migrations/0012_stockitem_deal.py
Normal file
20
warehouse/migrations/0012_stockitem_deal.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.3 on 2026-04-06 03:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shiftflow', '0018_alter_productionreportconsumption_unique_together_and_more'),
|
||||
('warehouse', '0011_stockitem_is_customer_supplied'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='deal',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.deal', verbose_name='Сделка'),
|
||||
),
|
||||
]
|
||||
48
warehouse/models warehouse.py
Normal file
48
warehouse/models warehouse.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.db import models
|
||||
|
||||
class MaterialCategory(models.Model):
|
||||
"""Категория материала (например, Труба, Лист, Круг)"""
|
||||
name = models.CharField("Название категории", max_length=100, unique=True)
|
||||
gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Категория материала"
|
||||
verbose_name_plural = "Категории материалов"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class SteelGrade(models.Model):
|
||||
"""Марка стали (например, Ст3сп, 09Г2С) и связанные с ней ГОСТы"""
|
||||
name = models.CharField("Марка стали", max_length=100, unique=True)
|
||||
gost_standard = models.CharField("ГОСТ/ТУ", max_length=255, blank=True, help_text="Основной стандарт для этой марки")
|
||||
certificate_pdf = models.FileField("Сертификат/ГОСТ (PDF)", upload_to='certificates/', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Марка стали"
|
||||
verbose_name_plural = "Марки стали"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name
|
||||
|
||||
class Material(models.Model):
|
||||
"""Конкретная номенклатурная единица (например, Труба 100х100х4)"""
|
||||
category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория")
|
||||
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
|
||||
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
|
||||
full_name = models.CharField("Полное наименование", max_length=500, blank=True, help_text="Генерируется автоматически")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Материал (номенклатура)"
|
||||
verbose_name_plural = "Материалы"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
category_part = (self.category.name or '').strip() if self.category_id else ''
|
||||
name_part = (self.name or '').strip()
|
||||
grade_part = (self.steel_grade.name or '').strip() if self.steel_grade_id else ''
|
||||
|
||||
self.full_name = ' '.join([p for p in [category_part, name_part, grade_part] if p])
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or ' '.join([p for p in [(self.category.name if self.category_id else ''), self.name, (self.steel_grade.name if self.steel_grade_id else '')] if p]).strip()
|
||||
@@ -1,8 +1,20 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
import uuid
|
||||
|
||||
class MaterialCategory(models.Model):
|
||||
"""Категория материала (например, Труба, Лист, Круг)"""
|
||||
|
||||
FORM_FACTOR_CHOICES = [
|
||||
('sheet', 'Лист'),
|
||||
('bar', 'Прокат/хлыст'),
|
||||
('other', 'Прочее'),
|
||||
]
|
||||
|
||||
name = models.CharField("Название категории", max_length=100, unique=True)
|
||||
form_factor = models.CharField('Форма', max_length=16, choices=FORM_FACTOR_CHOICES, default='other')
|
||||
gost_standard = models.CharField("ГОСТ на тип проката", max_length=255, blank=True, help_text="Напр: ГОСТ 8639-82")
|
||||
|
||||
class Meta:
|
||||
@@ -26,7 +38,7 @@ class SteelGrade(models.Model):
|
||||
return f"{self.name} ({self.gost_standard})" if self.gost_standard else self.name
|
||||
|
||||
class Material(models.Model):
|
||||
"""Конкретная номенклатурная единица (например, Труба 100х100х4)"""
|
||||
"""Конкретная номенклатурная единица (например, Труба 100х100х4)."""
|
||||
category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, verbose_name="Категория")
|
||||
steel_grade = models.ForeignKey(SteelGrade, on_delete=models.PROTECT, verbose_name="Марка стали", null=True, blank=True)
|
||||
name = models.CharField("Наименование (размер/характеристики)", max_length=255)
|
||||
@@ -46,3 +58,134 @@ class Material(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or ' '.join([p for p in [(self.category.name if self.category_id else ''), self.name, (self.steel_grade.name if self.steel_grade_id else '')] if p]).strip()
|
||||
|
||||
|
||||
class Location(models.Model):
|
||||
"""Место хранения: центральный склад или склад участка (лазер/гибка/сварка и т.д.)."""
|
||||
|
||||
name = models.CharField("Место хранения", max_length=100, unique=True)
|
||||
is_production_area = models.BooleanField("Это производственный участок", default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Склад/Участок"
|
||||
verbose_name_plural = "Склады и участки"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class StockItem(models.Model):
|
||||
"""Физический остаток на складе.
|
||||
|
||||
Правила заполнения:
|
||||
- если это сырьё: заполнен material, entity пустой
|
||||
- если это готовая деталь: заполнен entity, material пустой
|
||||
|
||||
Количество (quantity) интерпретируется как "единица учёта" для конкретного объекта:
|
||||
- для листа может быть 1 лист
|
||||
- для профиля/трубы может быть 1 хлыст
|
||||
- для готовых деталей обычно шт.
|
||||
"""
|
||||
|
||||
material = models.ForeignKey('warehouse.Material', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Сырьё")
|
||||
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Произведённая сущность")
|
||||
|
||||
deal = models.ForeignKey('shiftflow.Deal', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Сделка')
|
||||
|
||||
location = models.ForeignKey(Location, on_delete=models.PROTECT, verbose_name="Где находится")
|
||||
quantity = models.FloatField("Количество (шт/м/кг/лист)")
|
||||
created_at = models.DateTimeField("Поступление", default=timezone.now, editable=False)
|
||||
created_at = models.DateTimeField("Поступление", default=timezone.now, editable=False)
|
||||
|
||||
is_remnant = models.BooleanField("Деловой остаток", default=False)
|
||||
is_customer_supplied = models.BooleanField('Давальческий', default=False)
|
||||
current_length = models.FloatField("Текущая длина, мм", null=True, blank=True)
|
||||
current_width = models.FloatField("Текущая ширина, мм", null=True, blank=True)
|
||||
unique_id = models.CharField("ID/Маркировка (для ДО)", max_length=50, unique=True, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Единица на складе"
|
||||
verbose_name_plural = "Единицы на складе"
|
||||
|
||||
def clean(self):
|
||||
if self.material_id and not self.entity_id:
|
||||
category = getattr(self.material, 'category', None)
|
||||
form_factor = getattr(category, 'form_factor', 'other') if category else 'other'
|
||||
|
||||
if form_factor == 'sheet':
|
||||
if self.current_length in (None, '') or self.current_width in (None, ''):
|
||||
raise ValidationError('Для листового материала нужно заполнить длину и ширину (мм).')
|
||||
|
||||
if form_factor == 'bar':
|
||||
if self.current_length in (None, ''):
|
||||
raise ValidationError('Для проката нужно заполнить длину (мм).')
|
||||
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_remnant and not self.unique_id:
|
||||
while True:
|
||||
candidate = f"DO-{uuid.uuid4().hex[:12].upper()}"
|
||||
if not StockItem.objects.filter(unique_id=candidate).exists():
|
||||
self.unique_id = candidate
|
||||
break
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
obj = self.entity if self.entity_id else self.material
|
||||
return f"{obj} | {self.quantity} | {self.location}"
|
||||
|
||||
|
||||
class TransferRecord(models.Model):
|
||||
"""Документ перемещения между складами.
|
||||
|
||||
Состав перемещения хранится в строках TransferLine, где указывается:
|
||||
- какая складская позиция списывается со склада-источника
|
||||
- сколько списывается
|
||||
|
||||
Статусы «в пути/принято» можно расширить позже. Сейчас ключевое — корректное списание/начисление.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('sent', 'В пути'),
|
||||
('received', 'Принято'),
|
||||
('discrepancy', 'Расхождение'),
|
||||
]
|
||||
|
||||
from_location = models.ForeignKey(Location, related_name='outgoing', on_delete=models.PROTECT, verbose_name='Откуда')
|
||||
to_location = models.ForeignKey(Location, related_name='incoming', on_delete=models.PROTECT, verbose_name='Куда')
|
||||
|
||||
sender = models.ForeignKey(User, related_name='sent_transfers', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Кто')
|
||||
receiver = models.ForeignKey(User, related_name='received_transfers', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Кому')
|
||||
|
||||
occurred_at = models.DateTimeField('Дата/время', default=timezone.now)
|
||||
|
||||
status = models.CharField("Статус", max_length=20, choices=STATUS_CHOICES, default='received')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
received_at = models.DateTimeField(null=True, blank=True, default=timezone.now)
|
||||
|
||||
is_applied = models.BooleanField('Применено', default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Перемещение"
|
||||
verbose_name_plural = "Перемещения"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.from_location} -> {self.to_location}"
|
||||
|
||||
|
||||
class TransferLine(models.Model):
|
||||
"""Строка перемещения: сколько списать из конкретной складской позиции."""
|
||||
|
||||
transfer = models.ForeignKey(TransferRecord, related_name='lines', on_delete=models.CASCADE, verbose_name='Перемещение')
|
||||
stock_item = models.ForeignKey(StockItem, on_delete=models.PROTECT, verbose_name='Единица на складе')
|
||||
quantity = models.FloatField('Количество')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Строка перемещения'
|
||||
verbose_name_plural = 'Строки перемещения'
|
||||
unique_together = ('transfer', 'stock_item')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.transfer_id}: {self.stock_item_id} x {self.quantity}"
|
||||
|
||||
5
warehouse/services/__init__.py
Normal file
5
warehouse/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Сервисный слой приложения warehouse.
|
||||
|
||||
Здесь живут операции складского учёта, требующие транзакций и блокировок.
|
||||
"""
|
||||
71
warehouse/services/transfers.py
Normal file
71
warehouse/services/transfers.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from warehouse.models import StockItem, TransferLine, TransferRecord
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def receive_transfer(transfer_id: int, receiver_id: int) -> None:
|
||||
"""
|
||||
Строгое перемещение: принять TransferRecord.
|
||||
|
||||
Логика:
|
||||
- если уже received -> идемпотентно выходим
|
||||
- блокируем TransferRecord
|
||||
- блокируем связанные StockItem
|
||||
- обновляем location на to_location
|
||||
- ставим receiver/received_at/status
|
||||
"""
|
||||
tr = (
|
||||
TransferRecord.objects.select_for_update()
|
||||
.select_related('from_location', 'to_location')
|
||||
.get(pk=transfer_id)
|
||||
)
|
||||
|
||||
if tr.is_applied:
|
||||
return
|
||||
|
||||
lines = list(TransferLine.objects.filter(transfer=tr).select_related('stock_item', 'stock_item__location', 'stock_item__material', 'stock_item__entity'))
|
||||
if not lines:
|
||||
raise RuntimeError('В перемещении нет строк.')
|
||||
|
||||
for ln in lines:
|
||||
if float(ln.quantity) <= 0:
|
||||
continue
|
||||
|
||||
src = StockItem.objects.select_for_update().get(pk=ln.stock_item_id)
|
||||
if src.location_id != tr.from_location_id:
|
||||
raise RuntimeError('Единица на складе находится не на складе-источнике.')
|
||||
|
||||
if float(ln.quantity) > float(src.quantity):
|
||||
raise RuntimeError('Недостаточно количества в источнике для перемещения.')
|
||||
|
||||
if src.unique_id and float(ln.quantity) != float(src.quantity):
|
||||
raise RuntimeError('Нельзя частично перемещать позицию с ID/маркировкой.')
|
||||
|
||||
if float(ln.quantity) == float(src.quantity):
|
||||
src.location_id = tr.to_location_id
|
||||
src.created_at = timezone.now()
|
||||
src.save(update_fields=['location', 'created_at'])
|
||||
continue
|
||||
|
||||
src.quantity = float(src.quantity) - float(ln.quantity)
|
||||
src.save(update_fields=['quantity'])
|
||||
|
||||
# ВАЖНО: не объединяем с существующими позициями на складе-получателе,
|
||||
# чтобы сохранялась история поступлений/дат/партий.
|
||||
StockItem.objects.create(
|
||||
material=src.material,
|
||||
entity=src.entity,
|
||||
location_id=tr.to_location_id,
|
||||
quantity=float(ln.quantity),
|
||||
is_remnant=src.is_remnant,
|
||||
current_length=src.current_length,
|
||||
current_width=src.current_width,
|
||||
)
|
||||
|
||||
tr.status = 'received'
|
||||
tr.receiver_id = receiver_id
|
||||
tr.received_at = timezone.now()
|
||||
tr.is_applied = True
|
||||
tr.save(update_fields=['status', 'receiver_id', 'received_at', 'is_applied'])
|
||||
Reference in New Issue
Block a user