This commit is contained in:
@@ -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',)
|
||||
29
shiftflow/management/commands/shiftflow_explode_deal.py
Normal file
29
shiftflow/management/commands/shiftflow_explode_deal.py
Normal 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-деталях.')
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='Цех'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
18
shiftflow/migrations/0019_alter_employeeprofile_role.py
Normal file
18
shiftflow/migrations/0019_alter_employeeprofile_role.py
Normal 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='Должность'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
14
shiftflow/services/__init__.py
Normal file
14
shiftflow/services/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Сервисный слой приложения shiftflow.
|
||||
|
||||
Здесь живёт бизнес-логика, которую можно вызывать из:
|
||||
- view (HTTP)
|
||||
- admin
|
||||
- management commands
|
||||
- фоновых воркеров
|
||||
|
||||
Принцип:
|
||||
- сервисы не зависят от шаблонов/HTML,
|
||||
- сервисы работают с ORM и транзакциями,
|
||||
- сервисы содержат правила заводской логики (MES/ERP).
|
||||
"""
|
||||
265
shiftflow/services/bom_explosion.py
Normal file
265
shiftflow/services/bom_explosion.py
Normal 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)
|
||||
120
shiftflow/services/closing.py
Normal file
120
shiftflow/services/closing.py
Normal 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,
|
||||
)
|
||||
191
shiftflow/services/sessions.py
Normal file
191
shiftflow/services/sessions.py
Normal 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"])
|
||||
270
shiftflow/templates/shiftflow/closing.html
Normal file
270
shiftflow/templates/shiftflow/closing.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
560
shiftflow/templates/shiftflow/warehouse_stocks.html
Normal file
560
shiftflow/templates/shiftflow/warehouse_stocks.html
Normal 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 %}
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user