Огромная замена логики
All checks were successful
Deploy MES Core / deploy (push) Successful in 11s

This commit is contained in:
2026-04-06 08:06:37 +03:00
parent 0e8497ab1f
commit e88b861f68
48 changed files with 3833 additions and 175 deletions

View File

@@ -1,28 +1,94 @@
import os
from django.contrib import admin
from .models import Company, EmployeeProfile, Machine, Deal, ProductionTask, Item
from django.contrib import admin, messages
from shiftflow.services.sessions import close_cutting_session
from warehouse.models import StockItem
from .models import (
Company,
CuttingSession,
Deal,
DealItem,
DxfPreviewJob,
DxfPreviewSettings,
EmployeeProfile,
Item,
Machine,
MaterialRequirement,
ProductionReportConsumption,
ProductionReportRemnant,
ProductionTask,
ShiftItem,
Workshop,
)
# --- Настройка отображения Компаний ---
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
list_display = ('name', 'description')
search_fields = ('name',)
"""
Панель администрирования Компаний
"""
list_display = ('name', 'description') # Что видим в общем списке
search_fields = ('name',) # Поиск по имени
class DealItemInline(admin.TabularInline):
model = DealItem
fields = ('entity', 'quantity')
autocomplete_fields = ('entity',)
extra = 10
# --- Настройка отображения Сделок ---
@admin.register(Deal)
class DealAdmin(admin.ModelAdmin):
list_display = ('number', 'status', 'company')
"""
Панель администрирования Сделок
"""
list_display = ('number', 'id', 'status', 'company')
list_display_links = ('number',)
search_fields = ('number', 'company__name')
list_filter = ('status', 'company')
inlines = (DealItemInline,)
# --- Задания на производство (База) ---
"""
Панель администрирования Заданий на производство
"""
@admin.register(ProductionTask)
class ProductionTaskAdmin(admin.ModelAdmin):
list_display = ('drawing_name', 'deal', 'material', 'quantity_ordered', 'created_at')
search_fields = ('drawing_name', 'deal__number')
list_display = ('drawing_name', 'deal', 'entity', 'material', 'quantity_ordered', 'created_at')
search_fields = ('drawing_name', 'deal__number', 'entity__name', 'entity__drawing_number')
list_filter = ('deal', 'material', 'is_bend')
autocomplete_fields = ('deal', 'entity', 'material')
# --- Сменные задания (Выполнение) ---
"""
Панель администрирования Сделочных элементов
"""
@admin.register(DealItem)
class DealItemAdmin(admin.ModelAdmin):
"""
Панель администрирования Сделочных элементов
"""
list_display = ('deal', 'entity', 'quantity')
search_fields = ('deal__number', 'entity__name', 'entity__drawing_number')
list_filter = ('deal',)
autocomplete_fields = ('deal', 'entity')
@admin.register(MaterialRequirement)
class MaterialRequirementAdmin(admin.ModelAdmin):
"""
Панель администрирования Требований к Материалам
"""
list_display = ('deal', 'material', 'required_qty', 'unit', 'status')
search_fields = ('deal__number', 'material__name', 'material__full_name')
list_filter = ('status', 'unit', 'material__category')
autocomplete_fields = ('deal', 'material')
"""
Панель администрирования Сменных задания (Выполнение)
"""
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
# Что видим в общем списке (используем task__ для доступа к полям базы)
@@ -53,13 +119,147 @@ class ItemAdmin(admin.ModelAdmin):
return obj.task.drawing_name if obj.task else "-"
get_drawing.short_description = 'Деталь'
@admin.register(Workshop)
class WorkshopAdmin(admin.ModelAdmin):
list_display = ('name', 'location')
search_fields = ('name',)
list_filter = ('location',)
@admin.register(Machine)
class MachineAdmin(admin.ModelAdmin):
list_display = ('name', 'machine_type')
list_filter = ('machine_type',)
list_display = ('name', 'machine_type', 'workshop', 'location')
list_display_links = ('name',)
list_filter = ('machine_type', 'workshop')
search_fields = ('name',)
fields = ('name', 'machine_type', 'workshop', 'location')
class ProductionReportLineInline(admin.TabularInline):
model = ShiftItem
fk_name = 'session'
fields = ('task', 'quantity_fact', 'material_substitution')
extra = 5
class ProductionReportConsumptionInline(admin.TabularInline):
model = ProductionReportConsumption
fk_name = 'report'
fields = ('stock_item', 'quantity')
autocomplete_fields = ('stock_item',)
extra = 3
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'stock_item':
report = getattr(request, '_production_report_obj', None)
if report and getattr(report, 'machine_id', None):
machine = report.machine
work_location = None
if getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None):
work_location = machine.workshop.location
elif getattr(machine, 'location_id', None):
work_location = machine.location
if work_location:
kwargs['queryset'] = StockItem.objects.filter(location=work_location, material__isnull=False)
else:
kwargs['queryset'] = StockItem.objects.none()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class ProductionReportRemnantInline(admin.TabularInline):
model = ProductionReportRemnant
fk_name = 'report'
fields = ('material', 'quantity', 'current_length', 'current_width')
autocomplete_fields = ('material',)
extra = 3
@admin.register(CuttingSession)
class CuttingSessionAdmin(admin.ModelAdmin):
"""
Панель администрирования Производственных отчетов.
Ограничение по складу:
- списание сырья доступно только со склада цеха выбранного станка.
"""
list_display = ('date', 'id', 'machine', 'operator', 'used_stock_item', 'is_closed')
list_display_links = ('date',)
list_filter = ('date', 'machine', 'is_closed')
search_fields = ('operator__username',)
actions = ('action_close_sessions',)
inlines = (ProductionReportLineInline, ProductionReportConsumptionInline, ProductionReportRemnantInline)
def get_form(self, request, obj=None, **kwargs):
request._production_report_obj = obj
return super().get_form(request, obj, **kwargs)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'used_stock_item':
report = getattr(request, '_production_report_obj', None)
if report and getattr(report, 'machine_id', None):
machine = report.machine
work_location = None
if getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None):
work_location = machine.workshop.location
elif getattr(machine, 'location_id', None):
work_location = machine.location
if work_location:
kwargs['queryset'] = StockItem.objects.filter(location=work_location, material__isnull=False)
else:
kwargs['queryset'] = StockItem.objects.none()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.action(description='Закрыть производственный отчет')
def action_close_sessions(self, request, queryset):
ok = 0
skipped = 0
failed = 0
for s in queryset:
try:
if s.is_closed:
skipped += 1
continue
close_cutting_session(s.id)
ok += 1
except Exception as e:
failed += 1
self.message_user(request, f'Отчет id={s.id}: {e}', level=messages.ERROR)
if ok:
self.message_user(request, f'Закрыто: {ok}.', level=messages.SUCCESS)
if skipped:
self.message_user(request, f'Пропущено (уже закрыто): {skipped}.', level=messages.WARNING)
if failed:
self.message_user(request, f'Ошибок: {failed}.', level=messages.ERROR)
@admin.register(ShiftItem)
class ShiftItemAdmin(admin.ModelAdmin):
list_display = ('session', 'task', 'quantity_fact', 'material_substitution')
list_filter = ('material_substitution',)
autocomplete_fields = ('session', 'task')
class DxfPreviewSettingsAdmin(admin.ModelAdmin):
list_display = (
'line_color',
'lineweight_scaling',
'min_lineweight',
'keep_original_colors',
'per_task_timeout_sec',
'updated_at',
)
@admin.register(DxfPreviewJob)
class DxfPreviewJobAdmin(admin.ModelAdmin):
list_display = ('id', 'status', 'created_by', 'processed', 'total', 'updated', 'errors', 'started_at', 'finished_at')
list_filter = ('status',)
search_fields = ('last_message',)
@admin.register(EmployeeProfile)
class EmployeeProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'role')
filter_horizontal = ('machines',) # Красивый выбор станков двумя колонками
filter_horizontal = ('machines',)

View File

@@ -0,0 +1,29 @@
from django.core.management.base import BaseCommand
from shiftflow.models import DealItem
from shiftflow.services.bom_explosion import explode_deal
class Command(BaseCommand):
help = "BOM Explosion для сделки: генерирует ProductionTask и MaterialRequirement."
def add_arguments(self, parser):
parser.add_argument("deal_id", type=int)
def handle(self, *args, **options):
deal_id = int(options["deal_id"])
stats = explode_deal(deal_id)
self.stdout.write(
self.style.SUCCESS(
f"OK deal={deal_id} tasks_created={stats.tasks_created} tasks_updated={stats.tasks_updated} "
f"req_created={stats.req_created} req_updated={stats.req_updated}"
)
)
if stats.tasks_created == 0 and stats.tasks_updated == 0 and stats.req_created == 0 and stats.req_updated == 0:
di_count = DealItem.objects.filter(deal_id=deal_id).count()
if di_count == 0:
self.stdout.write('Подсказка: в сделке нет позиций (DealItem). Добавь DealItem и повтори команду.')
else:
self.stdout.write('Подсказка: проверь заполнение BOM и норм расхода (blank_area_m2/blank_length_mm) на leaf-деталях.')

View File

@@ -0,0 +1,88 @@
# Generated by Django 6.0.3 on 2026-04-04 15:14
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 = [
('manufacturing', '0001_initial'),
('shiftflow', '0014_dxfpreviewjob_cancel_requested_dxfpreviewjob_pid_and_more'),
('warehouse', '0004_location_stockitem_transferrecord'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='machine',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад участка'),
),
migrations.AddField(
model_name='productiontask',
name='entity',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='КД (изделие/деталь)'),
),
migrations.CreateModel(
name='CuttingSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.localdate, verbose_name='Дата')),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_closed', models.BooleanField(default=False, verbose_name='Сессия закрыта')),
('machine', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.machine', verbose_name='Станок')),
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Оператор')),
('used_stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Взятый материал')),
],
options={
'verbose_name': 'Сессия раскроя',
'verbose_name_plural': 'Сессии раскроя',
},
),
migrations.CreateModel(
name='MaterialRequirement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('required_qty', models.FloatField(verbose_name='Нужно докупить')),
('unit', models.CharField(choices=[('m2', 'м²'), ('mm', 'мм'), ('pcs', 'шт')], default='pcs', max_length=8, verbose_name='Ед. изм.')),
('status', models.CharField(choices=[('needed', 'К закупке'), ('ordered', 'В пути'), ('fulfilled', 'Обеспечено')], default='needed', max_length=20, verbose_name='Статус')),
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shiftflow.deal', verbose_name='Сделка')),
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал')),
],
options={
'verbose_name': 'Потребность',
'verbose_name_plural': 'Потребности',
},
),
migrations.CreateModel(
name='ShiftItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity_fact', models.PositiveIntegerField(default=0, verbose_name='Изготовлено (факт), шт')),
('material_substitution', models.BooleanField(default=False, verbose_name='Замена материала по факту')),
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='shiftflow.cuttingsession')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='shiftflow.productiontask', verbose_name='Плановое задание')),
],
options={
'verbose_name': 'Пункт сессии',
'verbose_name_plural': 'Пункты сессий',
},
),
migrations.CreateModel(
name='DealItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(verbose_name='Заказано, шт')),
('deal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shiftflow.deal', verbose_name='Сделка')),
('entity', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='manufacturing.productentity', verbose_name='Изделие/деталь')),
],
options={
'verbose_name': 'Позиция сделки',
'verbose_name_plural': 'Позиции сделки',
'unique_together': {('deal', 'entity')},
},
),
]

View File

@@ -0,0 +1,77 @@
# Generated by Django 6.0.3 on 2026-04-05 07:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0015_machine_location_productiontask_entity_and_more'),
('warehouse', '0004_location_stockitem_transferrecord'),
]
operations = [
migrations.AlterModelOptions(
name='cuttingsession',
options={'verbose_name': 'Производственный отчет', 'verbose_name_plural': 'Производственные отчеты'},
),
migrations.AlterModelOptions(
name='shiftitem',
options={'verbose_name': 'Фиксация выработки', 'verbose_name_plural': 'Фиксации выработки'},
),
migrations.AlterField(
model_name='cuttingsession',
name='is_closed',
field=models.BooleanField(default=False, verbose_name='Отчет закрыт'),
),
migrations.AlterField(
model_name='cuttingsession',
name='used_stock_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Взятый материал (legacy)'),
),
migrations.CreateModel(
name='ProductionReportRemnant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.FloatField(default=1.0, 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, verbose_name='ID/Маркировка')),
('material', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remnants', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')),
],
options={
'verbose_name': 'Деловой остаток',
'verbose_name_plural': 'Деловые остатки',
},
),
migrations.CreateModel(
name='ProductionReportConsumption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.FloatField(verbose_name='Списано (ед.)')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consumptions', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')),
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Сырье (позиция склада)')),
],
options={
'verbose_name': 'Списание сырья',
'verbose_name_plural': 'Списание сырья',
'unique_together': {('report', 'stock_item')},
},
),
migrations.CreateModel(
name='ProductionReportStockResult',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('finished', 'Готовая деталь'), ('remnant', 'Деловой остаток')], max_length=16, verbose_name='Тип')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='shiftflow.cuttingsession', verbose_name='Производственный отчет')),
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Созданная позиция склада')),
],
options={
'verbose_name': 'Результат отчета',
'verbose_name_plural': 'Результаты отчета',
'unique_together': {('report', 'stock_item')},
},
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 6.0.3 on 2026-04-05 08:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0016_alter_cuttingsession_options_alter_shiftitem_options_and_more'),
('warehouse', '0005_alter_stockitem_options'),
]
operations = [
migrations.AlterField(
model_name='machine',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад участка (устаревает)'),
),
migrations.CreateModel(
name='Workshop',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True, verbose_name='Цех')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.location', verbose_name='Склад цеха')),
],
options={
'verbose_name': 'Цех',
'verbose_name_plural': 'Цеха',
},
),
migrations.AddField(
model_name='machine',
name='workshop',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='shiftflow.workshop', verbose_name='Цех'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.3 on 2026-04-05 09:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0017_alter_machine_location_workshop_machine_workshop'),
('warehouse', '0006_alter_stockitem_options'),
]
operations = [
migrations.AlterUniqueTogether(
name='productionreportconsumption',
unique_together=set(),
),
migrations.AddField(
model_name='productionreportconsumption',
name='material',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.material', verbose_name='Материал'),
),
migrations.AlterField(
model_name='productionreportconsumption',
name='stock_item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='warehouse.stockitem', verbose_name='Сырье (позиция склада, legacy)'),
),
migrations.AlterUniqueTogether(
name='productionreportconsumption',
unique_together={('report', 'material')},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-04-06 04:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shiftflow', '0018_alter_productionreportconsumption_unique_together_and_more'),
]
operations = [
migrations.AlterField(
model_name='employeeprofile',
name='role',
field=models.CharField(choices=[('admin', 'Администратор'), ('technologist', 'Технолог'), ('master', 'Мастер'), ('operator', 'Оператор'), ('clerk', 'Учетчик'), ('observer', 'Наблюдатель')], default='operator', max_length=20, verbose_name='Должность'),
),
]

View File

@@ -15,10 +15,31 @@ class Company(models.Model):
class Meta:
verbose_name = "Компания"; verbose_name_plural = "Компании"
class Machine(models.Model):
class Workshop(models.Model):
"""Цех/участок верхнего уровня.
Логика доступа к складу:
- оператор и станок работают со складом цеха;
- перемещения между складами (центральный <-> цех <-> следующий цех) выполняются через «Перемещение».
"""
Список производственных участков (станков).
Используется для фильтрации сменных заданий для конкретных операторов.
name = models.CharField('Цех', max_length=120, unique=True)
location = models.ForeignKey('warehouse.Location', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Склад цеха')
class Meta:
verbose_name = 'Цех'
verbose_name_plural = 'Цеха'
def __str__(self):
return self.name
class Machine(models.Model):
"""Список производственных участков (станков).
Источник склада для операций выработки/списаний:
- предпочитаем склад цеха (Machine.workshop.location)
- поле Machine.location оставлено для совместимости (если цех не задан)
"""
MACHINE_TYPE_CHOICES = [
@@ -28,6 +49,8 @@ class Machine(models.Model):
name = models.CharField("Название станка", max_length=100)
machine_type = models.CharField("Тип станка", max_length=10, choices=MACHINE_TYPE_CHOICES, default='linear')
workshop = models.ForeignKey('shiftflow.Workshop', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Цех')
location = models.ForeignKey('warehouse.Location', on_delete=models.PROTECT, null=True, blank=True, verbose_name="Склад участка (устаревает)")
def __str__(self):
return self.name
@@ -59,11 +82,15 @@ class Deal(models.Model):
verbose_name = "Сделка"; verbose_name_plural = "Сделки"
class ProductionTask(models.Model):
"""План производства детали по сделке.
Переходный этап:
- сейчас в задаче ещё есть legacy-поля (drawing_name, файлы, material), чтобы не сломать UI;
- целевая модель: task.entity -> manufacturing.ProductEntity, а файлы/превью живут на entity.
"""
Основание для производства. Определяет ЧТО делать.
Создается технологом или мастером на основе заказа.
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="КД (изделие/деталь)")
drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
size_value = models.FloatField("Размер детали", help_text="Длина (мм) или Толщина (мм)")
@@ -133,6 +160,171 @@ class DxfPreviewSettings(models.Model):
return "Настройки превью DXF"
class DealItem(models.Model):
"""Состав сделки: что заказал клиент (точка входа для BOM Explosion)."""
deal = models.ForeignKey(Deal, related_name='items', on_delete=models.CASCADE, verbose_name='Сделка')
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Изделие/деталь')
quantity = models.PositiveIntegerField('Заказано, шт')
class Meta:
verbose_name = 'Позиция сделки'
verbose_name_plural = 'Позиции сделки'
unique_together = ('deal', 'entity')
def __str__(self):
return f"{self.deal.number}: {self.entity} x{self.quantity}"
class MaterialRequirement(models.Model):
"""Потребность в закупке сырья для сделки.
required_qty хранит величину в unit:
- для листа: m2
- для профиля/трубы: mm
Статус отражает этап обеспечения.
"""
STATUS_CHOICES = [
('needed', 'К закупке'),
('ordered', 'В пути'),
('fulfilled', 'Обеспечено'),
]
UNIT_CHOICES = [
('m2', 'м²'),
('mm', 'мм'),
('pcs', 'шт'),
]
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name='Материал')
required_qty = models.FloatField('Нужно докупить')
unit = models.CharField('Ед. изм.', max_length=8, choices=UNIT_CHOICES, default='pcs')
status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='needed')
class Meta:
verbose_name = 'Потребность'
verbose_name_plural = 'Потребности'
def __str__(self):
return f"{self.deal.number}: {self.material} -> {self.required_qty} {self.unit}"
class CuttingSession(models.Model):
"""Производственный отчет (основа для списания/начисления).
Основная идея документа:
- оператор фиксирует выработку по нескольким плановым заданиям за смену;
- списание сырья на участке может включать несколько позиций (листы/хлысты/куски);
- по итогам могут появляться несколько деловых остатков.
used_stock_item — legacy-поле для упрощённого случая «списали 1 единицу сырья».
Для реального списания используем ProductionReportConsumption.
"""
operator = models.ForeignKey(User, on_delete=models.PROTECT, verbose_name='Оператор')
machine = models.ForeignKey(Machine, on_delete=models.PROTECT, verbose_name='Станок')
used_stock_item = models.ForeignKey(
'warehouse.StockItem',
on_delete=models.PROTECT,
null=True,
blank=True,
verbose_name='Взятый материал (legacy)',
)
date = models.DateField('Дата', default=timezone.localdate)
created_at = models.DateTimeField(auto_now_add=True)
is_closed = models.BooleanField('Отчет закрыт', default=False)
class Meta:
verbose_name = 'Производственный отчет'
verbose_name_plural = 'Производственные отчеты'
def __str__(self):
return f"{self.date} {self.machine} ({self.operator})"
class ProductionReportConsumption(models.Model):
"""Строка списания сырья в рамках производственного отчёта.
Переходная схема:
- целевой ввод делается по номенклатуре (material);
- legacy-поле stock_item оставлено временно, чтобы мигрировать существующие записи.
После переноса данных stock_item будет удалён, а material станет обязательным.
"""
report = models.ForeignKey(CuttingSession, related_name='consumptions', on_delete=models.CASCADE, verbose_name='Производственный отчет')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, null=True, blank=True, verbose_name='Материал')
stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, null=True, blank=True, verbose_name='Сырье (позиция склада, legacy)')
quantity = models.FloatField('Списано (ед.)')
class Meta:
verbose_name = 'Списание сырья'
verbose_name_plural = 'Списание сырья'
unique_together = ('report', 'material')
def __str__(self):
return f"{self.report_id}: {self.material} - {self.quantity}"
class ProductionReportRemnant(models.Model):
"""Деловой остаток, который нужно начислить по итогам производственного отчёта."""
report = models.ForeignKey(CuttingSession, related_name='remnants', on_delete=models.CASCADE, verbose_name='Производственный отчет')
material = models.ForeignKey(WarehouseMaterial, on_delete=models.PROTECT, verbose_name='Материал')
quantity = models.FloatField('Количество (ед.)', default=1.0)
current_length = models.FloatField('Текущая длина, мм', null=True, blank=True)
current_width = models.FloatField('Текущая ширина, мм', null=True, blank=True)
unique_id = models.CharField('ID/Маркировка', max_length=50, null=True, blank=True)
class Meta:
verbose_name = 'Деловой остаток'
verbose_name_plural = 'Деловые остатки'
def __str__(self):
return f"{self.report_id}: {self.material}"
class ProductionReportStockResult(models.Model):
"""След созданных складских позиций по отчету (готовые детали и деловые остатки)."""
KIND_CHOICES = [
('finished', 'Готовая деталь'),
('remnant', 'Деловой остаток'),
]
report = models.ForeignKey(CuttingSession, related_name='results', on_delete=models.CASCADE, verbose_name='Производственный отчет')
stock_item = models.ForeignKey('warehouse.StockItem', on_delete=models.PROTECT, verbose_name='Созданная позиция склада')
kind = models.CharField('Тип', max_length=16, choices=KIND_CHOICES)
class Meta:
verbose_name = 'Результат отчета'
verbose_name_plural = 'Результаты отчета'
unique_together = ('report', 'stock_item')
def __str__(self):
return f"{self.report_id}: {self.stock_item_id}"
class ShiftItem(models.Model):
"""Фиксация выработки в рамках производственного отчёта."""
session = models.ForeignKey(CuttingSession, related_name='tasks', on_delete=models.CASCADE)
task = models.ForeignKey(ProductionTask, on_delete=models.PROTECT, verbose_name='Плановое задание')
quantity_fact = models.PositiveIntegerField('Изготовлено (факт), шт', default=0)
material_substitution = models.BooleanField('Замена материала по факту', default=False)
class Meta:
verbose_name = 'Фиксация выработки'
verbose_name_plural = 'Фиксации выработки'
def __str__(self):
return f"{self.session} -> {self.task}"
class DxfPreviewJob(models.Model):
"""Фоновая задача пакетной регенерации превью DXF.
@@ -239,6 +431,7 @@ class EmployeeProfile(models.Model):
('master', 'Мастер'),
('operator', 'Оператор'),
('clerk', 'Учетчик'),
('observer', 'Наблюдатель'),
]
# Связь 1 к 1 со стандартным юзером Django

View File

@@ -0,0 +1,14 @@
"""
Сервисный слой приложения shiftflow.
Здесь живёт бизнес-логика, которую можно вызывать из:
- view (HTTP)
- admin
- management commands
- фоновых воркеров
Принцип:
- сервисы не зависят от шаблонов/HTML,
- сервисы работают с ORM и транзакциями,
- сервисы содержат правила заводской логики (MES/ERP).
"""

View File

@@ -0,0 +1,265 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from django.db import transaction
from django.db.models import Sum
from django.db.models.functions import Coalesce
from manufacturing.models import BOM, ProductEntity
from shiftflow.models import Deal, DealItem, MaterialRequirement, ProductionTask
from warehouse.models import Location, StockItem
@dataclass(frozen=True)
class ExplosionStats:
"""
Сводка результата BOM Explosion.
tasks_*:
- сколько ProductionTask создано/обновлено (по leaf-деталям)
req_*:
- сколько MaterialRequirement создано/обновлено (по сырью)
"""
tasks_created: int
tasks_updated: int
req_created: int
req_updated: int
def _category_kind(material_category_name: str) -> str:
"""
Определение типа материала по названию категории.
Возвращает:
- 'sheet' для листовых материалов
- 'linear' для профилей/труб/круга
- 'unknown' если не удалось определить
"""
s = (material_category_name or "").strip().lower()
if "лист" in s:
return "sheet"
if any(k in s for k in ["труба", "проф", "круг", "швел", "угол", "балк", "квадрат"]):
return "linear"
return "unknown"
def _norm_and_unit(entity: ProductEntity) -> tuple[float | None, str]:
"""
Возвращает норму расхода и единицу измерения для MaterialRequirement.
Логика:
- для листа берём blank_area_m2 (м²/шт)
- для линейного берём blank_length_mm (мм/шт)
Если категория не распознана, но одна из норм задана — используем заданную.
"""
if not entity.planned_material_id or not getattr(entity.planned_material, "category_id", None):
if entity.blank_area_m2:
return float(entity.blank_area_m2), "m2"
if entity.blank_length_mm:
return float(entity.blank_length_mm), "mm"
return None, "pcs"
kind = _category_kind(entity.planned_material.category.name)
if kind == "sheet":
return (float(entity.blank_area_m2) if entity.blank_area_m2 else None), "m2"
if kind == "linear":
return (float(entity.blank_length_mm) if entity.blank_length_mm else None), "mm"
if entity.blank_area_m2:
return float(entity.blank_area_m2), "m2"
if entity.blank_length_mm:
return float(entity.blank_length_mm), "mm"
return None, "pcs"
def _build_bom_graph(root_entity_ids: set[int]) -> dict[int, list[tuple[int, int]]]:
"""
Строит граф BOM в памяти для заданного множества root entity.
Возвращает:
adjacency[parent_id] = [(child_id, qty), ...]
"""
adjacency: dict[int, list[tuple[int, int]]] = defaultdict(list)
frontier = set(root_entity_ids)
seen = set()
while frontier:
batch = frontier - seen
if not batch:
break
seen |= batch
rows = BOM.objects.filter(parent_id__in=batch).values_list("parent_id", "child_id", "quantity")
next_frontier = set()
for parent_id, child_id, qty in rows:
q = int(qty or 0)
if q <= 0:
continue
adjacency[int(parent_id)].append((int(child_id), q))
next_frontier.add(int(child_id))
frontier |= next_frontier
return adjacency
def _explode_to_leaves(
entity_id: int,
adjacency: dict[int, list[tuple[int, int]]],
memo: dict[int, dict[int, int]],
visiting: set[int],
) -> dict[int, int]:
"""
Возвращает разложение entity_id в leaf-детали в виде:
{ leaf_entity_id: multiplier_for_one_unit }
"""
if entity_id in memo:
return memo[entity_id]
if entity_id in visiting:
raise RuntimeError("Цикл в BOM: спецификация зациклена.")
visiting.add(entity_id)
children = adjacency.get(entity_id) or []
if not children:
memo[entity_id] = {entity_id: 1}
visiting.remove(entity_id)
return memo[entity_id]
out: dict[int, int] = defaultdict(int)
for child_id, qty in children:
child_map = _explode_to_leaves(child_id, adjacency, memo, visiting)
for leaf_id, leaf_qty in child_map.items():
out[leaf_id] += int(qty) * int(leaf_qty)
memo[entity_id] = dict(out)
visiting.remove(entity_id)
return memo[entity_id]
@transaction.atomic
def explode_deal(
deal_id: int,
*,
central_location_name: str = "Центральный склад",
) -> ExplosionStats:
"""
BOM Explosion:
- берём состав сделки (DealItem)
- рекурсивно обходим BOM
- считаем суммарное количество leaf-деталей
- создаём/обновляем ProductionTask (deal + entity)
- создаём/обновляем MaterialRequirement по нормам расхода и остаткам на центральном складе
"""
deal = Deal.objects.select_for_update().get(pk=deal_id)
deal_items = list(DealItem.objects.select_related("entity").filter(deal=deal))
if not deal_items:
return ExplosionStats(0, 0, 0, 0)
root_ids = {di.entity_id for di in deal_items}
adjacency = _build_bom_graph(root_ids)
memo: dict[int, dict[int, int]] = {}
required_leaves: dict[int, int] = defaultdict(int)
for di in deal_items:
leaf_map = _explode_to_leaves(di.entity_id, adjacency, memo, set())
for leaf_id, qty_per_unit in leaf_map.items():
required_leaves[leaf_id] += int(di.quantity) * int(qty_per_unit)
leaf_entities = {
e.id: e
for e in ProductEntity.objects.select_related("planned_material", "planned_material__category")
.filter(id__in=list(required_leaves.keys()))
}
tasks_created = 0
tasks_updated = 0
for entity_id, qty in required_leaves.items():
entity = leaf_entities.get(entity_id)
if not entity:
continue
pt, created = ProductionTask.objects.get_or_create(
deal=deal,
entity=entity,
defaults={
"drawing_name": entity.name or "Б/ч",
"size_value": 0,
"material": entity.planned_material,
"quantity_ordered": int(qty),
"is_bend": False,
},
)
if created:
tasks_created += 1
else:
changed = False
if pt.quantity_ordered != int(qty):
pt.quantity_ordered = int(qty)
changed = True
if not pt.material_id and entity.planned_material_id:
pt.material = entity.planned_material
changed = True
if changed:
pt.save(update_fields=["quantity_ordered", "material"])
tasks_updated += 1
central, _ = Location.objects.get_or_create(
name=central_location_name,
defaults={"is_production_area": False},
)
req_created = 0
req_updated = 0
for entity_id, qty_parts in required_leaves.items():
entity = leaf_entities.get(entity_id)
if not entity or not entity.planned_material_id:
continue
per_unit, unit = _norm_and_unit(entity)
if not per_unit:
continue
required_qty = float(qty_parts) * float(per_unit)
available = (
StockItem.objects.filter(location=central, material=entity.planned_material)
.aggregate(v=Coalesce(Sum("quantity"), 0.0))["v"]
)
to_buy = max(0.0, required_qty - float(available or 0.0))
if to_buy <= 0:
continue
mr, created = MaterialRequirement.objects.get_or_create(
deal=deal,
material=entity.planned_material,
unit=unit,
defaults={"required_qty": to_buy, "status": "needed"},
)
if created:
req_created += 1
else:
mr.required_qty = to_buy
mr.status = "needed"
mr.save(update_fields=["required_qty", "status"])
req_updated += 1
return ExplosionStats(tasks_created, tasks_updated, req_created, req_updated)

View File

@@ -0,0 +1,120 @@
from django.db import transaction
from django.utils import timezone
from shiftflow.models import (
CuttingSession,
Item,
ProductionReportConsumption,
ProductionReportRemnant,
ShiftItem,
)
from shiftflow.services.sessions import close_cutting_session
from warehouse.models import StockItem
@transaction.atomic
def apply_closing(
*,
user_id: int,
machine_id: int,
material_id: int,
item_actions: dict[int, dict],
consumptions: dict[int, float],
remnants: list[dict],
) -> None:
items = list(
Item.objects.select_for_update(of=('self',))
.select_related('task', 'task__deal', 'task__material', 'machine')
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status='work', task__material_id=material_id)
)
if not items:
raise RuntimeError('Не найдено пунктов сменки для закрытия.')
report = CuttingSession.objects.create(
operator_id=user_id,
machine_id=machine_id,
used_stock_item=None,
date=timezone.localdate(),
is_closed=False,
)
for it in items:
spec = item_actions.get(it.id) or {}
action = (spec.get('action') or '').strip()
fact = int(spec.get('fact') or 0)
if action not in ['done', 'partial']:
continue
plan = int(it.quantity_plan or 0)
if plan <= 0:
continue
if action == 'done':
fact = plan
else:
fact = max(0, min(fact, plan))
if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
ShiftItem.objects.create(session=report, task=it.task, quantity_fact=fact)
for stock_item_id, qty in consumptions.items():
if qty <= 0:
continue
ProductionReportConsumption.objects.create(
report=report,
stock_item_id=stock_item_id,
material_id=None,
quantity=float(qty),
)
for r in remnants:
qty = float(r.get('quantity') or 0)
if qty <= 0:
continue
ProductionReportRemnant.objects.create(
report=report,
material_id=material_id,
quantity=qty,
current_length=r.get('current_length'),
current_width=r.get('current_width'),
unique_id=None,
)
close_cutting_session(report.id)
for it in items:
spec = item_actions.get(it.id) or {}
action = (spec.get('action') or '').strip()
fact = int(spec.get('fact') or 0)
if action not in ['done', 'partial']:
continue
plan = int(it.quantity_plan or 0)
if plan <= 0:
continue
if action == 'done':
it.quantity_fact = plan
it.status = 'done'
it.save(update_fields=['quantity_fact', 'status'])
continue
fact = max(0, min(fact, plan))
residual = plan - fact
it.quantity_fact = fact
it.status = 'partial'
it.save(update_fields=['quantity_fact', 'status'])
if residual > 0:
Item.objects.create(
task=it.task,
date=it.date,
machine=it.machine,
quantity_plan=residual,
quantity_fact=0,
status='leftover',
is_synced_1c=False,
)

View File

@@ -0,0 +1,191 @@
from django.db import transaction
from manufacturing.models import ProductEntity
from shiftflow.models import (
CuttingSession,
ProductionReportConsumption,
ProductionReportRemnant,
ProductionReportStockResult,
ShiftItem,
)
from warehouse.models import StockItem
@transaction.atomic
def close_cutting_session(session_id: int) -> None:
"""
Закрытие CuttingSession (транзакция склада).
A) Списать сырьё:
- уменьшаем used_stock_item.quantity на 1
- если стало 0 -> удаляем
B) Начислить готовые детали:
- для каждого ShiftItem создаём StockItem(entity=..., location=machine.location, quantity=quantity_fact)
- если использованный материал не совпадает с planned_material КД -> material_substitution=True
"""
session = (
CuttingSession.objects.select_for_update(of=('self',))
.select_related(
"machine",
"machine__location",
"machine__workshop",
"machine__workshop__location",
"used_stock_item",
"used_stock_item__material",
)
.get(pk=session_id)
)
if session.is_closed:
return
work_location = None
if getattr(session.machine, 'workshop_id', None) and getattr(session.machine.workshop, 'location_id', None):
work_location = session.machine.workshop.location
elif session.machine.location_id:
work_location = session.machine.location
if not work_location:
raise RuntimeError('Не задан склад цеха для станка (Цех -> Склад цеха).')
consumed_material_ids: set[int] = set()
consumptions = list(
ProductionReportConsumption.objects.select_related('material', 'stock_item', 'stock_item__material', 'stock_item__location')
.filter(report=session)
)
if consumptions:
for c in consumptions:
need = float(c.quantity)
if need <= 0:
continue
if c.stock_item_id:
si = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=c.stock_item_id)
if not si.material_id:
raise RuntimeError('В списании сырья указана позиция склада без material.')
if si.location_id != work_location.id:
raise RuntimeError('Списывать сырьё можно только со склада цеха станка.')
if need > float(si.quantity):
raise RuntimeError('Недостаточно количества в выбранной складской позиции.')
si.quantity = float(si.quantity) - need
if si.quantity == 0:
si.delete()
else:
si.save(update_fields=['quantity'])
consumed_material_ids.add(int(si.material_id))
continue
if not c.material_id:
raise RuntimeError('В списании сырья не указан материал.')
consumed_material_ids.add(int(c.material_id))
qs = (
StockItem.objects.select_for_update(of=('self',))
.select_related('material', 'location')
.filter(location=work_location, material_id=c.material_id, entity__isnull=True)
.order_by('id')
)
for si in qs:
if need <= 0:
break
take = min(float(si.quantity), need)
si.quantity = float(si.quantity) - take
need -= take
if si.quantity == 0:
si.delete()
else:
si.save(update_fields=['quantity'])
if need > 0:
raise RuntimeError('Недостаточно сырья на складе цеха станка для списания.')
else:
if not session.used_stock_item_id:
raise RuntimeError('Не заполнено списание сырья: добавь строки «Списание сырья» или укажи legacy поле «Взятый материал».')
used = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=session.used_stock_item_id)
if not used.material_id:
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).')
if used.location_id != work_location.id:
raise RuntimeError('Списывать сырьё можно только со склада цеха станка.')
used.quantity = float(used.quantity) - 1.0
if used.quantity < 0:
raise RuntimeError('Недостаточно сырья для списания.')
if used.quantity == 0:
used.delete()
else:
used.save(update_fields=['quantity'])
consumed_material_ids.add(int(used.material_id))
items = list(
ShiftItem.objects.select_related("task", "task__entity", "task__entity__planned_material", "task__material")
.filter(session=session)
)
for it in items:
if it.quantity_fact <= 0:
continue
task = it.task
planned_material = None
if task.entity_id and getattr(task.entity, 'planned_material_id', None):
planned_material = task.entity.planned_material
elif getattr(task, 'material_id', None):
planned_material = task.material
if planned_material and consumed_material_ids:
it.material_substitution = planned_material.id not in consumed_material_ids
else:
it.material_substitution = False
it.save(update_fields=['material_substitution'])
if not task.entity_id:
name = (getattr(task, 'drawing_name', '') or '').strip() or 'Без названия'
pe = ProductEntity.objects.create(
name=name[:255],
drawing_number=f"AUTO-{task.id}",
entity_type='part',
planned_material=planned_material,
)
task.entity = pe
task.save(update_fields=['entity'])
created = StockItem.objects.create(
entity=task.entity,
deal_id=getattr(task, 'deal_id', None),
location=work_location,
quantity=float(it.quantity_fact),
)
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='finished')
remnants = list(ProductionReportRemnant.objects.filter(report=session).select_related('material'))
for r in remnants:
created = StockItem.objects.create(
material=r.material,
location=work_location,
quantity=float(r.quantity),
is_remnant=True,
current_length=r.current_length,
current_width=r.current_width,
unique_id=r.unique_id,
)
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='remnant')
session.is_closed = True
session.save(update_fields=["is_closed"])

View File

@@ -0,0 +1,270 @@
{% extends 'base.html' %}
{% block content %}
<div class="card border-secondary mb-3 shadow-sm">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-center">
<div class="col-md-4">
<label class="small text-muted mb-1 fw-bold">Станок:</label>
<select class="form-select form-select-sm bg-body text-body border-secondary" name="machine_id" onchange="this.form.submit()">
<option value="">— выбрать —</option>
{% for m in machines %}
<option value="{{ m.id }}" {% if selected_machine_id == m.id|stringformat:"s" %}selected{% endif %}>{{ m.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="small text-muted mb-1 fw-bold">Материал:</label>
<select class="form-select form-select-sm bg-body text-body border-secondary" name="material_id" onchange="this.form.submit()">
<option value="">— выбрать —</option>
{% for mat in materials %}
<option value="{{ mat.id }}" {% if selected_material_id == mat.id|stringformat:"s" %}selected{% endif %}>{{ mat.full_name|default:mat.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 text-end mt-auto">
<a class="btn btn-outline-secondary btn-sm w-100" href="{% url 'closing' %}">Сброс</a>
</div>
</form>
</div>
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="machine_id" value="{{ selected_machine_id }}">
<input type="hidden" name="material_id" value="{{ selected_material_id }}">
<div class="card shadow border-secondary mb-3">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-check2-square me-2"></i>Закрытие</h3>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Дата</th>
<th>Сделка</th>
<th>Деталь</th>
<th>План</th>
<th data-sort="false">Факт</th>
<th data-sort="false">Режим</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr>
<td class="small">{{ it.date|date:"d.m.Y" }}</td>
<td><span class="text-accent fw-bold">{{ it.task.deal.number }}</span></td>
<td class="fw-bold">{{ it.task.drawing_name }}</td>
<td>{{ it.quantity_plan }}</td>
<td style="max-width:140px;">
<input class="form-control form-control-sm border-secondary" type="number" min="0" max="{{ it.quantity_plan }}" name="fact_{{ it.id }}" id="fact_{{ it.id }}" value="{{ it.quantity_fact }}" {% if not can_edit %}disabled{% endif %}>
</td>
<td style="min-width:260px;">
<div class="d-flex gap-2 align-items-center flex-wrap">
<button type="button" class="btn btn-sm btn-outline-success closing-set-action" data-item-id="{{ it.id }}" data-action="done" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Полностью</button>
<button type="button" class="btn btn-sm btn-outline-warning closing-set-action" data-item-id="{{ it.id }}" data-action="partial" data-plan="{{ it.quantity_plan }}" {% if not can_edit %}disabled{% endif %}>Частично</button>
<input type="hidden" id="ca_{{ it.id }}" name="close_action_{{ it.id }}" value="">
<span class="small text-muted" id="modeLabel_{{ it.id }}"></span>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-4">Выбери станок и материал</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card shadow border-secondary mb-3">
<div class="card-header border-secondary py-3">
<h5 class="mb-0">Списание со склада цеха (единицы)</h5>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Поступление</th>
<th>Единица</th>
<th>Доступно</th>
<th data-sort="false">Использовано</th>
</tr>
</thead>
<tbody>
{% for s in stock_items %}
<tr>
<td class="small">{% if s.created_at %}{{ s.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
<td>{{ s }}</td>
<td>{{ s.quantity }}</td>
<td style="max-width:140px;">
<input class="form-control form-control-sm border-secondary" name="consume_{{ s.id }}" placeholder="0" {% if not can_edit %}disabled{% endif %}>
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted py-4">Нет единиц на складе для выбранного материала</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Остаток ДО</h5>
<button type="button" class="btn btn-outline-accent btn-sm" id="addRemnantBtn" {% if not can_edit %}disabled{% endif %}>Добавить ДО</button>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead>
<tr class="table-custom-header">
<th>Кол-во</th>
<th>Длина (мм)</th>
<th>Ширина (мм)</th>
<th data-sort="false"></th>
</tr>
</thead>
<tbody id="remnantBody">
<tr id="remnantEmptyRow">
<td colspan="4" class="text-center text-muted py-4">ДО не добавлены</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-outline-accent" {% if not can_edit %}disabled{% endif %}>Сохранить</button>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', () => {
const canEdit = {% if can_edit %}true{% else %}false{% endif %};
document.querySelectorAll('.closing-set-action').forEach(btn => {
btn.addEventListener('click', () => {
if (!canEdit) return;
const itemId = btn.getAttribute('data-item-id');
const action = btn.getAttribute('data-action');
const plan = parseInt(btn.getAttribute('data-plan') || '0', 10) || 0;
const hidden = document.getElementById('ca_' + itemId);
const fact = document.getElementById('fact_' + itemId);
const label = document.getElementById('modeLabel_' + itemId);
if (hidden) hidden.value = action;
const cell = btn.closest('td');
if (cell) {
cell.querySelectorAll('.closing-set-action').forEach(b => {
const a = b.getAttribute('data-action');
if (a === 'done') {
b.classList.remove('btn-success');
b.classList.add('btn-outline-success');
}
if (a === 'partial') {
b.classList.remove('btn-warning');
b.classList.add('btn-outline-warning');
}
});
}
if (action === 'done') {
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-success');
if (fact) {
fact.value = String(plan);
fact.readOnly = true;
}
if (label) label.textContent = 'Выбрано: полностью';
}
if (action === 'partial') {
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-warning');
if (fact) {
fact.readOnly = false;
fact.focus();
fact.select();
}
if (label) label.textContent = 'Выбрано: частично';
}
});
});
const addBtn = document.getElementById('addRemnantBtn');
const body = document.getElementById('remnantBody');
const emptyRow = document.getElementById('remnantEmptyRow');
function renumberRemnants() {
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
rows.forEach((tr, idx) => {
const qty = tr.querySelector('input[data-field="qty"]');
const len = tr.querySelector('input[data-field="len"]');
const wid = tr.querySelector('input[data-field="wid"]');
if (qty) qty.name = 'remnant_qty_' + idx;
if (len) len.name = 'remnant_len_' + idx;
if (wid) wid.name = 'remnant_wid_' + idx;
});
if (emptyRow) {
emptyRow.style.display = rows.length ? 'none' : '';
}
}
function addRemnantRow() {
if (!canEdit) return;
const rows = Array.from(body.querySelectorAll('tr[data-remnant-row="1"]'));
if (rows.length >= 50) return;
const tr = document.createElement('tr');
tr.setAttribute('data-remnant-row', '1');
tr.innerHTML = `
<td style="max-width:180px;">
<input class="form-control form-control-sm border-secondary" data-field="qty" inputmode="decimal" placeholder="Кол-во" required>
</td>
<td style="max-width:180px;">
<input class="form-control form-control-sm border-secondary" data-field="len" inputmode="decimal" placeholder="Длина (мм)">
</td>
<td style="max-width:180px;">
<input class="form-control form-control-sm border-secondary" data-field="wid" inputmode="decimal" placeholder="Ширина (мм)">
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="remove">Удалить</button>
</td>
`;
const rm = tr.querySelector('button[data-action="remove"]');
if (rm) {
rm.addEventListener('click', () => {
tr.remove();
renumberRemnants();
});
}
body.appendChild(tr);
renumberRemnants();
const first = tr.querySelector('input[data-field="qty"]');
if (first) {
first.focus();
first.select();
}
}
if (addBtn) {
addBtn.addEventListener('click', addRemnantRow);
}
renumberRemnants();
});
</script>
{% endblock %}

View File

@@ -134,39 +134,11 @@
<input type="number" name="quantity_fact" id="id_quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}" max="{{ item.quantity_plan }}">
</div>
<div class="col-md-4">
<label class="small text-muted">Взятый материал</label>
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м" required>
</div>
<div class="col-md-4">
<label class="small text-muted">Остаток ДО</label>
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: 0.8м / 12кг" required>
</div>
<div class="col-md-4">
<label class="small text-muted">Лом (кг)</label>
<input type="number" step="0.01" min="0" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}" required>
</div>
</div>
</div>
{% else %}
<div class="alert alert-success">Статус: {{ item.get_status_display }}. Сделано: {{ item.quantity_fact }} шт.</div>
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
{% if user_role == 'master' %}
<div class="row g-3 mt-3 text-start">
<div class="col-md-4">
<label class="small text-muted">Взятый материал</label>
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м">
</div>
<div class="col-md-4">
<label class="small text-muted">Остаток ДО</label>
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: 0.8м / 12кг">
</div>
<div class="col-md-4">
<label class="small text-muted">Лом (кг)</label>
<input type="number" step="0.01" min="0" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}">
</div>
</div>
{% endif %}
{% endif %}
{% endif %}
@@ -202,18 +174,6 @@
<input type="number" name="quantity_fact" class="form-control border-secondary" value="{{ item.quantity_fact }}">
</div>
<div class="col-md-4">
<label class="small text-muted">Взятый материал</label>
<input type="text" name="material_taken" class="form-control border-secondary" value="{{ item.material_taken }}" placeholder="Напр: 3 трубы по 12м">
</div>
<div class="col-md-4">
<label class="small text-muted">Деловой отход</label>
<input type="text" name="usable_waste" class="form-control border-secondary" value="{{ item.usable_waste }}" placeholder="Напр: кусок 1500мм">
</div>
<div class="col-md-4">
<label class="small text-muted">Лом (кг)</label>
<input type="number" step="0.01" name="scrap_weight" class="form-control border-secondary" value="{{ item.scrap_weight|default_if_none:'0'|unlocalize }}">
</div>
</div>
<div class="form-check form-switch p-3 rounded border border-warning mb-4 bg-body-tertiary d-flex justify-content-between align-items-center">
@@ -223,28 +183,13 @@
{% endif %}
{% if user_role == 'clerk' %}
<div class="row g-3 mb-4">
<div class="col-md-4">
<small class="text-muted d-block">Взятый материал</small>
<strong>{{ item.material_taken|default:"-" }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Остаток ДО</small>
<strong>{{ item.usable_waste|default:"-" }}</strong>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Лом (кг)</small>
<strong>{{ item.scrap_weight }}</strong>
</div>
</div>
{% if item.status == 'done' or item.status == 'partial' %}
<div class="form-check form-switch p-3 rounded border border-warning mb-4 bg-body-tertiary d-flex justify-content-between align-items-center">
<label class="form-check-label fw-bold ms-2" for="sync1c">Списано в 1С</label>
<input class="form-check-input ms-0" style="width: 3em; height: 1.5em;" type="checkbox" name="is_synced_1c" id="sync1c" {% if item.is_synced_1c %}checked{% endif %}>
</div>
{% else %}
<div class="text-muted small mb-4"><i class="bi bi-info-circle me-1"></i>Списание будет доступно после закрытия (Выполнено/Частично).</div>
<div class="text-muted small mb-4"><i class="bi bi-info-circle me-1"></i>Списание будет доступно после закрытия.</div>
{% endif %}
<input type="hidden" name="quantity_fact" value="{{ item.quantity_fact }}">
{% endif %}
@@ -253,7 +198,7 @@
<a href="{{ back_url }}" class="btn btn-outline-secondary">Назад</a>
<div class="d-flex gap-2">
<input type="hidden" name="action" id="actionField" value="save">
{% if item.status == 'work' %}
{% if item.status == 'work' and user_role == 'admin' %}
<button type="submit" class="btn btn-success px-4" onclick="document.getElementById('actionField').value='close_done'">
<i class="bi bi-check-all me-2"></i>Выполнено
</button>

View File

@@ -0,0 +1,560 @@
{% extends 'base.html' %}
{% block content %}
<div class="card border-secondary mb-3 shadow-sm">
<div class="card-body py-2">
<form method="get" id="warehouse-filter-form" class="row g-2 align-items-center">
<input type="hidden" name="q" value="{{ q }}">
<div class="col-md-7">
<div class="small text-muted mb-1 fw-bold">Склады:</div>
<div class="d-flex flex-wrap gap-1">
<div>
<input type="radio" class="btn-check" name="location_id" id="wl_all" value="" {% if not selected_location_id %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-accent btn-sm" for="wl_all">Все</label>
</div>
{% for loc in locations %}
<div>
<input type="radio" class="btn-check" name="location_id" id="wl_{{ loc.id }}" value="{{ loc.id }}" {% if selected_location_id == loc.id|stringformat:"s" %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-accent btn-sm" for="wl_{{ loc.id }}">{{ loc }}</label>
</div>
{% endfor %}
</div>
</div>
<div class="col-md-4">
<div class="small text-muted mb-1 fw-bold">Тип:</div>
<div class="d-flex flex-wrap gap-1">
<div>
<input type="radio" class="btn-check" name="kind" id="wk_all" value="" {% if not selected_kind %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-accent btn-sm" for="wk_all">Все</label>
</div>
<div>
<input type="radio" class="btn-check" name="kind" id="wk_raw" value="raw" {% if selected_kind == 'raw' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-primary btn-sm" for="wk_raw">Сырьё</label>
</div>
<div>
<input type="radio" class="btn-check" name="kind" id="wk_finished" value="finished" {% if selected_kind == 'finished' %}checked{% endif %} onchange="this.form.submit()">
<label class="btn btn-outline-success btn-sm" for="wk_finished">Изделия</label>
</div>
</div>
</div>
<div class="col-md-auto">
<div class="small text-muted mb-1 fw-bold">Период:</div>
<div class="d-flex gap-2">
<input type="date" name="start_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ start_date }}" onchange="this.form.submit()">
<input type="date" name="end_date" class="form-control form-control-sm bg-body text-body border-secondary" value="{{ end_date }}" onchange="this.form.submit()">
</div>
</div>
<div class="col-md-1 text-end mt-auto">
<a href="{% url 'warehouse_stocks' %}?reset=1" class="btn btn-outline-secondary btn-sm w-100" title="Сброс">
<i class="bi bi-arrow-counterclockwise"></i>
</a>
</div>
</form>
</div>
</div>
<div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex flex-wrap gap-2 justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-box-seam me-2"></i>Склады</h3>
<div class="d-flex flex-wrap gap-2 align-items-center">
{% if can_receive %}
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#receiptModal">
<i class="bi bi-box-arrow-in-down me-1"></i>Приход
</button>
{% endif %}
<form class="d-flex gap-2 align-items-center" method="get" action="{% url 'warehouse_stocks' %}">
<input type="hidden" name="location_id" value="{{ selected_location_id }}">
<input type="hidden" name="kind" value="{{ selected_kind }}">
<input type="hidden" name="start_date" value="{{ start_date }}">
<input type="hidden" name="end_date" value="{{ end_date }}">
<input class="form-control form-control-sm" name="q" value="{{ q }}" placeholder="Поиск (материал, деталь, склад, ID)" style="min-width: 360px;">
<button class="btn btn-outline-secondary btn-sm" type="submit"><i class="bi bi-search me-1"></i>Найти</button>
</form>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle" data-sortable="1">
<thead>
<tr class="table-custom-header">
<th>Склад</th>
<th data-sort-type="date">Поступление</th>
<th>Сделка</th>
<th>Наименование</th>
<th>Тип</th>
<th data-sort-type="number">Кол-во</th>
<th>Ед. измерения</th>
<th>ДО</th>
<th data-sort="false">Действия</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr>
<td>{{ it.location }}</td>
<td>{% if it.created_at %}{{ it.created_at|date:"d.m.Y H:i" }}{% endif %}</td>
<td>
{% if it.deal_id %}
<span class="text-accent fw-bold">{{ it.deal.number }}</span>
{% else %}
{% endif %}
</td>
<td>
{% if it.material_id %}
{{ it.material.full_name }}
{% elif it.entity_id %}
{{ it.entity }}
{% else %}
{% endif %}
{% if it.unique_id %}
<div class="small text-muted mt-1">{{ it.unique_id }}</div>
{% endif %}
</td>
<td>
{% if it.entity_id %}
Изделие/деталь
{% elif it.is_remnant %}
ДО
{% else %}
Сырьё
{% endif %}
</td>
<td>{{ it.quantity }}</td>
<td>
{% if it.entity_id %}
шт
{% elif it.material_id and it.material.category_id %}
{% with ff=it.material.category.form_factor|stringformat:"s"|lower %}
{% if ff == 'лист' or ff == 'sheet' %}лист
{% elif ff == 'прокат' or ff == 'rolled' or ff == 'roll' %}прокат
{% else %}ед.
{% endif %}
{% endwith %}
{% else %}
ед.
{% endif %}
</td>
<td>
{% if it.is_remnant %}Да{% else %}—{% endif %}
</td>
<td>
{% if can_transfer %}
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-outline-accent btn-sm"
data-bs-toggle="modal"
data-bs-target="#transferModal"
data-mode="transfer"
data-stock-item-id="{{ it.id }}"
data-stock-item-name="{% if it.material_id %}{{ it.material.full_name }}{% elif it.entity_id %}{{ it.entity }}{% else %}—{% endif %}"
data-from-location="{{ it.location }}"
data-from-location-id="{{ it.location_id }}"
data-max="{{ it.quantity }}"
>
<i class="bi bi-arrow-left-right me-1"></i>Переместить
</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#transferModal"
data-mode="ship"
data-stock-item-id="{{ it.id }}"
data-stock-item-name="{% if it.material_id %}{{ it.material.full_name }}{% elif it.entity_id %}{{ it.entity }}{% else %}—{% endif %}"
data-from-location="{{ it.location }}"
data-from-location-id="{{ it.location_id }}"
data-max="{{ it.quantity }}"
>
<i class="bi bi-truck me-1"></i>Отгрузка
</button>
</div>
{% else %}
<span class="text-muted small">только просмотр</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8" class="text-center text-muted py-4">Нет позиций по текущим фильтрам</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="receiptModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<form method="post" action="{% url 'warehouse_receipt' %}" class="modal-content border-secondary">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="modal-header border-secondary">
<h5 class="modal-title">Приход</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Тип</label>
<select class="form-select" name="kind" id="receiptKind" required>
<option value="raw">Сырьё / покупное</option>
<option value="entity">Изделие/деталь</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Сделка</label>
<select class="form-select" name="deal_id">
<option value="">— не указано —</option>
{% for d in deals %}
<option value="{{ d.id }}">{{ d.number }}{% if d.company_id %} — {{ d.company.name }}{% endif %}{% if d.description %} — {{ d.description }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Склад</label>
<select class="form-select" name="location_id" required>
{% for loc in locations %}
<option value="{{ loc.id }}">{{ loc }}</option>
{% endfor %}
</select>
</div>
<div class="col-12" id="receiptRawBlock">
<label class="form-label">Материал</label>
<select class="form-select" name="material_id" id="receiptMaterial">
{% for m in materials %}
<option value="{{ m.id }}" data-ff="{{ m.category.form_factor|default:'' }}">{{ m.full_name|default:m.name }}</option>
{% endfor %}
</select>
<div class="row g-2 mt-1">
<div class="col-md-4">
<label class="form-label">Кол-во</label>
<input class="form-control" name="quantity" id="receiptQtyRaw" placeholder="Напр. 1" required>
</div>
<div class="col-md-4">
<label class="form-label">Длина (мм)</label>
<input class="form-control" name="current_length" id="receiptLen" placeholder="Напр. 2500">
</div>
<div class="col-md-4">
<label class="form-label">Ширина (мм)</label>
<input class="form-control" name="current_width" id="receiptWid" placeholder="Напр. 1250">
</div>
<div class="col-12 mt-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_customer_supplied" id="receiptDav">
<label class="form-check-label" for="receiptDav">Давальческий</label>
</div>
</div>
</div>
</div>
<div class="col-12" id="receiptEntityBlock" style="display:none;">
<div class="row g-2">
<div class="col-md-8">
<label class="form-label">КД (изделие/деталь)</label>
<select class="form-select" name="entity_id">
{% for e in entities %}
<option value="{{ e.id }}">{{ e }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Кол-во</label>
<input class="form-control" name="quantity" id="receiptQtyEntity" placeholder="Напр. 1" required>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent">Добавить</button>
</div>
</form>
</div>
</div>
<div class="modal fade" id="transferModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<form method="post" action="{% url 'warehouse_transfer' %}" class="modal-content border-secondary">
{% csrf_token %}
<input type="hidden" name="stock_item_id" id="transferStockItemId">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="transferTitle">Перемещение</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<div class="fw-bold" id="transferInfoFrom"></div>
<div class="small text-muted" id="transferInfoName"></div>
<div class="small text-muted" id="transferInfoAvail"></div>
</div>
<div class="alert alert-danger d-none" id="transferError" role="alert"></div>
<div class="row g-2">
<div class="col-md-6" id="transferToCol">
<label class="form-label">Куда</label>
<select class="form-select" name="to_location_id" id="transferToLocation" required>
{% if shipping_location_id %}
<option value="{{ shipping_location_id }}" data-shipping="1" hidden disabled>{{ shipping_location_label|default:'Отгруженные позиции' }}</option>
{% endif %}
{% for loc in locations %}
<option value="{{ loc.id }}">{{ loc }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Количество</label>
<input class="form-control" name="quantity" id="transferQty" placeholder="Напр. 1 или 2.5" inputmode="decimal" autofocus required>
<div class="small text-muted mt-1" id="transferMaxHint"></div>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent">Применить</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('transferModal');
if (!modal) return;
const form = modal.querySelector('form');
const idInput = document.getElementById('transferStockItemId');
const title = document.getElementById('transferTitle');
const infoFrom = document.getElementById('transferInfoFrom');
const infoName = document.getElementById('transferInfoName');
const infoAvail = document.getElementById('transferInfoAvail');
const errBox = document.getElementById('transferError');
const qty = document.getElementById('transferQty');
const maxHint = document.getElementById('transferMaxHint');
const toSel = document.getElementById('transferToLocation');
const toCol = document.getElementById('transferToCol');
const receiptKind = document.getElementById('receiptKind');
const receiptRaw = document.getElementById('receiptRawBlock');
const receiptEntity = document.getElementById('receiptEntityBlock');
const receiptMaterial = document.getElementById('receiptMaterial');
const receiptLen = document.getElementById('receiptLen');
const receiptWid = document.getElementById('receiptWid');
const receiptQtyRaw = document.getElementById('receiptQtyRaw');
const receiptQtyEntity = document.getElementById('receiptQtyEntity');
let currentMax = null;
let currentMode = 'transfer';
function showErr(text) {
if (!errBox) return;
if (!text) {
errBox.classList.add('d-none');
errBox.textContent = '';
return;
}
errBox.textContent = text;
errBox.classList.remove('d-none');
}
function parseNumber(text) {
const s = (text || '').toString().trim().replace(',', '.');
const n = parseFloat(s);
return isNaN(n) ? null : n;
}
modal.addEventListener('show.bs.modal', (ev) => {
const btn = ev.relatedTarget;
if (!btn) return;
currentMode = btn.getAttribute('data-mode') || 'transfer';
const stockItemId = btn.getAttribute('data-stock-item-id') || '';
const name = btn.getAttribute('data-stock-item-name') || '';
const fromLoc = btn.getAttribute('data-from-location') || '';
const fromLocId = btn.getAttribute('data-from-location-id') || '';
const maxRaw = btn.getAttribute('data-max') || '';
currentMax = parseNumber(maxRaw);
showErr('');
if (idInput) idInput.value = stockItemId;
if (title) title.textContent = currentMode === 'ship' ? 'Отгрузка' : 'Перемещение';
if (infoFrom) infoFrom.textContent = `Откуда: ${fromLoc}`;
if (infoName) infoName.textContent = `Что: ${name}`;
if (infoAvail) infoAvail.textContent = currentMax !== null ? `Доступно: ${currentMax}` : '';
if (maxHint) maxHint.textContent = currentMax !== null ? `Доступно: ${currentMax}` : '';
if (qty) {
qty.value = currentMax !== null ? String(currentMax) : '';
if (currentMax !== null) qty.setAttribute('max', String(currentMax));
qty.setAttribute('min', '0');
qty.setAttribute('step', 'any');
}
if (toSel) {
const shipId = '{{ shipping_location_id }}';
Array.from(toSel.options).forEach(opt => {
const isShipping = opt.getAttribute('data-shipping') === '1';
if (isShipping) {
if (currentMode === 'ship') {
opt.disabled = false;
opt.hidden = false;
} else {
opt.disabled = true;
opt.hidden = true;
}
return;
}
if (fromLocId && String(opt.value) === String(fromLocId)) {
opt.disabled = true;
opt.hidden = true;
} else {
opt.disabled = false;
opt.hidden = false;
}
});
const col = toCol || (toSel ? toSel.closest('.col-md-6') : null);
if (currentMode === 'ship') {
if (shipId) {
toSel.value = shipId;
}
if (col) col.style.display = 'none';
} else {
if (col) col.style.display = '';
const first = Array.from(toSel.options).find(o => !o.disabled && !o.hidden);
if (first) toSel.value = first.value;
}
}
});
modal.addEventListener('shown.bs.modal', () => {
if (qty) {
qty.focus();
qty.select();
}
});
if (form) {
form.addEventListener('submit', (e) => {
const v = parseNumber(qty ? qty.value : '');
if (v === null || v <= 0) {
e.preventDefault();
showErr('Количество должно быть больше 0.');
if (qty) {
qty.focus();
qty.select();
}
return;
}
if (currentMax !== null && v > currentMax) {
e.preventDefault();
showErr('Нельзя переместить больше, чем доступно.');
if (qty) {
qty.focus();
qty.select();
}
return;
}
if (currentMode === 'ship') {
const shipId = '{{ shipping_location_id }}';
if (!shipId) {
e.preventDefault();
showErr('Не найден склад отгруженных позиций. Создай склад с названием содержащим "отгруж" или "отгруз".');
return;
}
}
showErr('');
});
}
function syncReceiptKind() {
if (!receiptKind || !receiptRaw || !receiptEntity) return;
const isRaw = (receiptKind.value || '') === 'raw';
receiptRaw.style.display = isRaw ? '' : 'none';
receiptEntity.style.display = isRaw ? 'none' : '';
if (receiptQtyRaw) {
receiptQtyRaw.disabled = !isRaw;
receiptQtyRaw.required = isRaw;
}
if (receiptQtyEntity) {
receiptQtyEntity.disabled = isRaw;
receiptQtyEntity.required = !isRaw;
}
}
function applyReceiptDefaults() {
if (!receiptMaterial) return;
const opt = receiptMaterial.options[receiptMaterial.selectedIndex];
const ff = (opt && opt.getAttribute('data-ff') || '').toLowerCase();
if (ff === 'sheet') {
if (receiptLen && !receiptLen.value) receiptLen.value = '2500';
if (receiptWid && !receiptWid.value) receiptWid.value = '1250';
if (receiptWid) receiptWid.disabled = false;
} else if (ff === 'bar') {
if (receiptLen && !receiptLen.value) receiptLen.value = '6000';
if (receiptWid) {
receiptWid.value = '';
receiptWid.disabled = true;
}
} else {
if (receiptWid) receiptWid.disabled = false;
}
}
if (receiptKind) {
receiptKind.addEventListener('change', () => {
syncReceiptKind();
if (receiptKind.value === 'raw') {
if (receiptQtyRaw) {
receiptQtyRaw.focus();
receiptQtyRaw.select();
}
} else {
if (receiptQtyEntity) {
receiptQtyEntity.focus();
receiptQtyEntity.select();
}
}
});
syncReceiptKind();
}
if (receiptMaterial) {
receiptMaterial.addEventListener('change', applyReceiptDefaults);
applyReceiptDefaults();
}
});
</script>
{% endblock %}

View File

@@ -20,6 +20,10 @@ from .views import (
RegistryView,
SteelGradeUpsertView,
TaskItemsView,
ClosingView,
WarehouseReceiptCreateView,
WarehouseStocksView,
WarehouseTransferCreateView,
)
urlpatterns = [
@@ -48,4 +52,10 @@ urlpatterns = [
# Печать сменного листа
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'),
path('warehouse/stocks/', WarehouseStocksView.as_view(), name='warehouse_stocks'),
path('warehouse/transfer/', WarehouseTransferCreateView.as_view(), name='warehouse_transfer'),
path('warehouse/receipt/', WarehouseReceiptCreateView.as_view(), name='warehouse_receipt'),
path('closing/', ClosingView.as_view(), name='closing'),
]

View File

@@ -14,6 +14,7 @@ from django.core.files.base import ContentFile
from django.db import close_old_connections
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When
from django.db.models import Q
from django.db.models.functions import Coalesce
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
@@ -23,7 +24,12 @@ from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils import timezone
from warehouse.models import Material, MaterialCategory, SteelGrade
from manufacturing.models import ProductEntity
from warehouse.models import Location, Material, MaterialCategory, SteelGrade, StockItem, TransferLine, TransferRecord
from warehouse.services.transfers import receive_transfer
from shiftflow.services.closing import apply_closing
from .forms import ProductionTaskCreateForm
from .models import Company, Deal, DxfPreviewJob, DxfPreviewSettings, EmployeeProfile, Item, Machine, ProductionTask
@@ -1193,9 +1199,11 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
template_name = 'shiftflow/item_detail.html'
# Перечисляем поля, которые можно редактировать в сменке
fields = [
'machine', 'quantity_plan', 'quantity_fact',
'status', 'is_synced_1c',
'material_taken', 'usable_waste', 'scrap_weight'
'machine',
'quantity_plan',
'quantity_fact',
'status',
'is_synced_1c',
]
context_object_name = 'item'
@@ -1296,15 +1304,6 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
self.object.quantity_fact = int(quantity_fact)
self.object.is_synced_1c = bool(request.POST.get('is_synced_1c'))
self.object.material_taken = request.POST.get('material_taken', self.object.material_taken)
self.object.usable_waste = request.POST.get('usable_waste', self.object.usable_waste)
scrap_weight = request.POST.get('scrap_weight')
if scrap_weight is not None and scrap_weight != '':
try:
self.object.scrap_weight = float(scrap_weight)
except ValueError:
pass
# Действия закрытия для админа/технолога
if action == 'close_done' and self.object.status == 'work':
@@ -1340,88 +1339,26 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
if role in ['operator', 'master']:
action = request.POST.get('action', 'save')
material_taken = (request.POST.get('material_taken') or '').strip()
usable_waste = (request.POST.get('usable_waste') or '').strip()
scrap_weight_raw = (request.POST.get('scrap_weight') or '').strip()
if action == 'save':
qf = request.POST.get('quantity_fact')
if qf and qf.isdigit():
self.object.quantity_fact = int(qf)
machine_changed = False
if role == 'master':
machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id)
machine_changed = True
fields = ['quantity_fact']
if machine_changed:
fields.append('machine')
self.object.save(update_fields=fields)
if action != 'save':
return redirect_back()
if self.object.status != 'work':
return redirect_back()
qf = request.POST.get('quantity_fact')
if qf and qf.isdigit():
self.object.quantity_fact = int(qf)
errors = []
if not material_taken:
errors.append('Заполни поле "Взятый материал"')
if not usable_waste:
errors.append('Заполни поле "Остаток ДО"')
if scrap_weight_raw == '':
errors.append('Заполни поле "Лом (кг)" (можно 0)')
machine_changed = False
if role == 'master':
machine_id = request.POST.get('machine')
if machine_id and machine_id.isdigit():
self.object.machine_id = int(machine_id)
machine_changed = True
scrap_weight = None
if scrap_weight_raw != '':
try:
scrap_weight = float(scrap_weight_raw)
except ValueError:
errors.append('Поле "Лом (кг)" должно быть числом')
if errors:
context = self.get_context_data()
context['errors'] = errors
return self.render_to_response(context)
self.object.material_taken = material_taken
self.object.usable_waste = usable_waste
if scrap_weight is not None:
self.object.scrap_weight = scrap_weight
if action == 'close_done':
self.object.quantity_fact = self.object.quantity_plan
self.object.status = 'done'
self.object.save()
return redirect_back()
if action == 'close_partial':
try:
fact = int(request.POST.get('quantity_fact', '0'))
except ValueError:
fact = 0
if fact <= 0:
context = self.get_context_data()
context['errors'] = ['При частичном закрытии укажи, сколько сделано (больше 0)']
return self.render_to_response(context)
fact = max(0, min(fact, self.object.quantity_plan))
residual = self.object.quantity_plan - fact
self.object.quantity_fact = fact
self.object.status = 'partial'
self.object.save()
if residual > 0:
Item.objects.create(
task=self.object.task,
date=self.object.date,
machine=self.object.machine,
quantity_plan=residual,
quantity_fact=0,
status='leftover',
is_synced_1c=False,
)
return redirect_back()
fields = ['quantity_fact']
if machine_changed:
fields.append('machine')
self.object.save(update_fields=fields)
return redirect_back()
if role == 'clerk':
@@ -1434,4 +1371,434 @@ class ItemUpdateView(LoginRequiredMixin, UpdateView):
return redirect_back()
def get_success_url(self):
return reverse_lazy('registry')
return reverse_lazy('registry')
class WarehouseStocksView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/warehouse_stocks.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk', 'observer']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
ship_loc = (
Location.objects.filter(
Q(name__icontains='отгруж')
| Q(name__icontains='Отгруж')
| Q(name__icontains='отгруз')
| Q(name__icontains='Отгруз')
)
.order_by('id')
.first()
)
ship_loc_id = ship_loc.id if ship_loc else None
locations_qs = Location.objects.all().order_by('name')
if ship_loc_id:
locations_qs = locations_qs.exclude(id=ship_loc_id)
locations = list(locations_qs)
ctx['locations'] = locations
q = (self.request.GET.get('q') or '').strip()
location_id = (self.request.GET.get('location_id') or '').strip()
kind = (self.request.GET.get('kind') or '').strip()
start_date = (self.request.GET.get('start_date') or '').strip()
end_date = (self.request.GET.get('end_date') or '').strip()
filtered = self.request.GET.get('filtered')
reset = self.request.GET.get('reset')
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate()
start = today - timezone.timedelta(days=21)
ctx['start_date'] = start.strftime('%Y-%m-%d')
ctx['end_date'] = today.strftime('%Y-%m-%d')
else:
ctx['start_date'] = start_date
ctx['end_date'] = end_date
qs = StockItem.objects.select_related('location', 'material', 'material__category', 'entity', 'deal').all()
if ship_loc_id:
qs = qs.exclude(location_id=ship_loc_id)
if location_id.isdigit():
qs = qs.filter(location_id=int(location_id))
start_val = ctx.get('start_date')
end_val = ctx.get('end_date')
if start_val:
qs = qs.filter(created_at__date__gte=start_val)
if end_val:
qs = qs.filter(created_at__date__lte=end_val)
if kind == 'raw':
qs = qs.filter(material__isnull=False, entity__isnull=True)
elif kind == 'finished':
qs = qs.filter(entity__isnull=False)
elif kind == 'remnant':
qs = qs.filter(is_remnant=True)
if q:
qs = qs.filter(
Q(material__full_name__icontains=q)
| Q(material__name__icontains=q)
| Q(entity__name__icontains=q)
| Q(entity__drawing_number__icontains=q)
| Q(unique_id__icontains=q)
| Q(location__name__icontains=q)
)
ctx['items'] = qs.order_by('-created_at', '-id')
ctx['selected_location_id'] = location_id
ctx['selected_kind'] = kind
ctx['q'] = q
ctx['can_transfer'] = role in ['admin', 'technologist', 'master', 'clerk']
ctx['can_receive'] = role in ['admin', 'technologist', 'master', 'clerk']
ctx['materials'] = Material.objects.select_related('category').all().order_by('full_name')
ctx['entities'] = ProductEntity.objects.all().order_by('drawing_number', 'name')
ctx['deals'] = Deal.objects.select_related('company').all().order_by('-id')
ctx['shipping_location_id'] = ship_loc_id or ''
ctx['shipping_location_label'] = ship_loc.name if ship_loc else ''
return ctx
class WarehouseTransferCreateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return JsonResponse({'error': 'forbidden'}, status=403)
stock_item_id = (request.POST.get('stock_item_id') or '').strip()
to_location_id = (request.POST.get('to_location_id') or '').strip()
qty_raw = (request.POST.get('quantity') or '').strip().replace(',', '.')
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('warehouse_stocks')
if not (stock_item_id.isdigit() and to_location_id.isdigit()):
messages.error(request, 'Заполни корректно: позиция склада и склад назначения.')
return redirect(next_url)
try:
qty = float(qty_raw)
except ValueError:
qty = 0.0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
si = get_object_or_404(StockItem.objects.select_related('location'), pk=int(stock_item_id))
if int(to_location_id) == si.location_id:
messages.error(request, 'Склад назначения должен отличаться от склада-источника.')
return redirect(next_url)
tr = TransferRecord.objects.create(
from_location_id=si.location_id,
to_location_id=int(to_location_id),
sender=request.user,
receiver=request.user,
occurred_at=timezone.now(),
status='received',
received_at=timezone.now(),
is_applied=False,
)
TransferLine.objects.create(transfer=tr, stock_item=si, quantity=qty)
try:
receive_transfer(tr.id, request.user.id)
messages.success(request, 'Операция применена.')
except Exception as e:
messages.error(request, f'Ошибка: {e}')
return redirect(next_url)
class WarehouseReceiptCreateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'technologist', 'master', 'clerk']:
return JsonResponse({'error': 'forbidden'}, status=403)
next_url = (request.POST.get('next') or '').strip()
if not next_url.startswith('/'):
next_url = reverse_lazy('warehouse_stocks')
kind = (request.POST.get('kind') or '').strip()
location_id = (request.POST.get('location_id') or '').strip()
deal_id = (request.POST.get('deal_id') or '').strip()
quantity_raw = (request.POST.get('quantity') or '').strip().replace(',', '.')
if not location_id.isdigit():
messages.error(request, 'Выбери склад.')
return redirect(next_url)
try:
qty = float(quantity_raw)
except ValueError:
qty = 0.0
if qty <= 0:
messages.error(request, 'Количество должно быть больше 0.')
return redirect(next_url)
if kind == 'raw':
material_id = (request.POST.get('material_id') or '').strip()
is_customer_supplied = bool(request.POST.get('is_customer_supplied'))
if not material_id.isdigit():
messages.error(request, 'Выбери материал.')
return redirect(next_url)
length_raw = (request.POST.get('current_length') or '').strip().replace(',', '.')
width_raw = (request.POST.get('current_width') or '').strip().replace(',', '.')
current_length = None
current_width = None
if length_raw:
try:
current_length = float(length_raw)
except ValueError:
current_length = None
if width_raw:
try:
current_width = float(width_raw)
except ValueError:
current_width = None
obj = StockItem(
material_id=int(material_id),
location_id=int(location_id),
deal_id=(int(deal_id) if deal_id.isdigit() else None),
quantity=float(qty),
is_customer_supplied=is_customer_supplied,
current_length=current_length,
current_width=current_width,
)
try:
obj.full_clean()
obj.save()
messages.success(request, 'Приход сырья добавлен.')
except Exception as e:
messages.error(request, f'Ошибка прихода: {e}')
return redirect(next_url)
if kind == 'entity':
entity_id = (request.POST.get('entity_id') or '').strip()
if not entity_id.isdigit():
messages.error(request, 'Выбери КД (изделие/деталь).')
return redirect(next_url)
obj = StockItem(
entity_id=int(entity_id),
location_id=int(location_id),
deal_id=(int(deal_id) if deal_id.isdigit() else None),
quantity=float(qty),
)
try:
obj.full_clean()
obj.save()
messages.success(request, 'Приход изделия добавлен.')
except Exception as e:
messages.error(request, f'Ошибка прихода: {e}')
return redirect(next_url)
messages.error(request, 'Выбери тип прихода.')
return redirect(next_url)
class ClosingView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/closing.html'
def dispatch(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'master', 'operator', 'observer']:
return redirect('registry')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = getattr(self.request.user, 'profile', None)
role = profile.role if profile else ('admin' if self.request.user.is_superuser else 'operator')
ctx['user_role'] = role
if role == 'operator' and profile:
machines = list(profile.machines.all().order_by('name'))
else:
machines = list(Machine.objects.all().order_by('name'))
ctx['machines'] = machines
ctx['materials'] = list(Material.objects.select_related('category').order_by('full_name'))
machine_id = (self.request.GET.get('machine_id') or '').strip()
material_id = (self.request.GET.get('material_id') or '').strip()
ctx['selected_machine_id'] = machine_id
ctx['selected_material_id'] = material_id
items = []
stock_items = []
if machine_id.isdigit() and material_id.isdigit():
items = list(
Item.objects.select_related('task', 'task__deal', 'task__material', 'machine')
.filter(machine_id=int(machine_id), status='work', task__material_id=int(material_id))
.order_by('date', 'task__deal__number', 'task__drawing_name')
)
machine = Machine.objects.select_related('workshop', 'workshop__location', 'location').filter(pk=int(machine_id)).first()
work_location_id = None
if machine and getattr(machine, 'workshop_id', None) and getattr(machine.workshop, 'location_id', None):
work_location_id = machine.workshop.location_id
elif machine and getattr(machine, 'location_id', None):
work_location_id = machine.location_id
if work_location_id:
stock_items = list(
StockItem.objects.select_related('location', 'material')
.filter(location_id=work_location_id, material_id=int(material_id), entity__isnull=True)
.order_by('created_at', 'id')
)
ctx['items'] = items
ctx['stock_items'] = stock_items
ctx['can_edit'] = role in ['admin', 'master', 'operator']
return ctx
def post(self, request, *args, **kwargs):
profile = getattr(request.user, 'profile', None)
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
if role not in ['admin', 'master', 'operator']:
return redirect('closing')
machine_id = (request.POST.get('machine_id') or '').strip()
material_id = (request.POST.get('material_id') or '').strip()
if not (machine_id.isdigit() and material_id.isdigit()):
messages.error(request, 'Выбери станок и материал.')
return redirect('closing')
item_actions = {}
for k, v in request.POST.items():
if not k.startswith('close_action_'):
continue
item_id = k.replace('close_action_', '')
if not item_id.isdigit():
continue
action = (v or '').strip()
if action not in ['done', 'partial']:
continue
fact_raw = (request.POST.get(f'fact_{item_id}') or '').strip()
try:
fact = int(fact_raw)
except ValueError:
fact = 0
item_actions[int(item_id)] = {'action': action, 'fact': fact}
consumptions = {}
for k, v in request.POST.items():
if not k.startswith('consume_'):
continue
sid = k.replace('consume_', '')
if not sid.isdigit():
continue
raw = (v or '').strip().replace(',', '.')
if not raw:
continue
try:
qty = float(raw)
except ValueError:
qty = 0.0
if qty > 0:
consumptions[int(sid)] = qty
remnants = []
idx = 0
while True:
has_any = (
f'remnant_qty_{idx}' in request.POST
or f'remnant_len_{idx}' in request.POST
or f'remnant_wid_{idx}' in request.POST
)
if not has_any:
break
qty_raw = (request.POST.get(f'remnant_qty_{idx}') or '').strip().replace(',', '.')
len_raw = (request.POST.get(f'remnant_len_{idx}') or '').strip().replace(',', '.')
wid_raw = (request.POST.get(f'remnant_wid_{idx}') or '').strip().replace(',', '.')
if qty_raw:
try:
rq = float(qty_raw)
except ValueError:
rq = 0.0
if rq > 0:
rl = None
rw = None
if len_raw:
try:
rl = float(len_raw)
except ValueError:
rl = None
if wid_raw:
try:
rw = float(wid_raw)
except ValueError:
rw = None
remnants.append({'quantity': rq, 'current_length': rl, 'current_width': rw})
idx += 1
if idx > 200:
break
if not item_actions:
messages.error(request, 'Выбери хотя бы один пункт сменки и режим закрытия (полностью/частично).')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
if not consumptions:
messages.error(request, 'Заполни списание: укажи, какие единицы на складе использованы и в каком количестве.')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
try:
apply_closing(
user_id=request.user.id,
machine_id=int(machine_id),
material_id=int(material_id),
item_actions=item_actions,
consumptions=consumptions,
remnants=remnants,
)
messages.success(request, 'Закрытие выполнено.')
except Exception as e:
messages.error(request, f'Ошибка закрытия: {e}')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")