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

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

View File

@@ -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}")