from datetime import datetime from django.db.models import Case, ExpressionWrapper, F, IntegerField, Sum, Value, When from django.db.models.functions import Coalesce from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy from django.views import View 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 .forms import ProductionTaskCreateForm from .models import Company, Deal, Item, Machine, ProductionTask # Класс главной страницы (роутер) class IndexView(TemplateView): template_name = 'shiftflow/landing.html' def get(self, request, *args, **kwargs): # Если юзер авторизован — сразу отправляем его в реестр if request.user.is_authenticated: return redirect('registry') # Если нет — показываем кнопку "Войти" return super().get(request, *args, **kwargs) # Класс реестра деталей (защищен LoginRequiredMixin) class RegistryView(LoginRequiredMixin, ListView): model = Item template_name = 'shiftflow/registry.html' context_object_name = 'items' def get_queryset(self): queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'machine') user = self.request.user profile = getattr(user, 'profile', None) role = profile.role if profile else 'operator' filtered = self.request.GET.get('filtered') # Станки m_ids = self.request.GET.getlist('m_ids') if filtered and role != 'operator' and not m_ids: return queryset.none() if m_ids: queryset = queryset.filter(machine_id__in=m_ids) # Статусы (+ агрегат "closed" = done+partial) statuses = self.request.GET.getlist('statuses') if filtered and not statuses: return queryset.none() if statuses: expanded = [] for s in statuses: if s == 'closed': expanded += ['done', 'partial'] else: expanded.append(s) queryset = queryset.filter(status__in=expanded) # Даты start_date = self.request.GET.get('start_date') end_date = self.request.GET.get('end_date') if not filtered: today = timezone.localdate() if role == 'clerk': queryset = queryset.filter(date=today, status__in=['done', 'partial']) elif role in ['operator', 'master']: queryset = queryset.filter(date=today, status__in=['work']) else: queryset = queryset.filter(date=today, status__in=['work', 'leftover']) else: if start_date: queryset = queryset.filter(date__gte=start_date) if end_date: queryset = queryset.filter(date__lte=end_date) # Списание (1С) is_synced = self.request.GET.get('is_synced') if is_synced in ['0', '1']: queryset = queryset.filter(is_synced_1c=bool(int(is_synced))) # Ограничения по ролям if role == 'operator': user_machines = profile.machines.all() if profile else Machine.objects.none() queryset = queryset.filter(machine__in=user_machines) if not filtered: queryset = queryset.filter(status='work') elif role == 'master' and not filtered: queryset = queryset.filter(status='work') return queryset.order_by('status', '-date', 'machine__name', 'task__deal__number') def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) user = self.request.user profile = getattr(user, 'profile', None) role = profile.role if profile else 'operator' context['user_role'] = role machines = Machine.objects.all() context['machines'] = machines filtered = self.request.GET.get('filtered') if not filtered: today_str = timezone.localdate().strftime('%Y-%m-%d') context['start_date'] = today_str context['end_date'] = today_str if role == 'clerk': context['selected_statuses'] = ['closed'] elif role in ['operator', 'master']: context['selected_statuses'] = ['work'] else: context['selected_statuses'] = ['work', 'leftover'] context['selected_machines'] = [m.id for m in machines] context['all_selected_machines'] = True else: context['selected_machines'] = [int(i) for i in self.request.GET.getlist('m_ids') if i.isdigit()] context['selected_statuses'] = self.request.GET.getlist('statuses') context['start_date'] = self.request.GET.get('start_date', '') context['end_date'] = self.request.GET.get('end_date', '') context['is_synced'] = self.request.GET.get('is_synced', '') context['all_selected_machines'] = False return context class RegistryPrintView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/registry_print.html' def dispatch(self, request, *args, **kwargs): profile = getattr(request.user, 'profile', None) role = profile.role if profile else 'operator' if role not in ['admin', 'technologist', 'master']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) profile = getattr(self.request.user, 'profile', None) role = profile.role if profile else 'operator' context['user_role'] = role queryset = Item.objects.select_related('task', 'task__deal', 'task__material', 'task__material__category', 'machine') filtered = self.request.GET.get('filtered') m_ids = self.request.GET.getlist('m_ids') if filtered and not m_ids: queryset = queryset.none() if m_ids: queryset = queryset.filter(machine_id__in=m_ids) statuses = self.request.GET.getlist('statuses') if filtered and not statuses: queryset = queryset.none() if statuses: expanded = [] for s in statuses: if s == 'closed': expanded += ['done', 'partial'] else: expanded.append(s) queryset = queryset.filter(status__in=expanded) start_date = self.request.GET.get('start_date') end_date = self.request.GET.get('end_date') if not filtered: today = timezone.localdate() queryset = queryset.filter(date=today, status__in=['work', 'leftover']) start_date = today.strftime('%Y-%m-%d') end_date = start_date else: if start_date: queryset = queryset.filter(date__gte=start_date) if end_date: queryset = queryset.filter(date__lte=end_date) is_synced = self.request.GET.get('is_synced') if is_synced in ['0', '1']: queryset = queryset.filter(is_synced_1c=bool(int(is_synced))) if role == 'master' and not filtered: queryset = queryset.filter(status='work') items = list(queryset.order_by('machine__name', 'date', 'task__deal__number', 'id')) groups = {} for item in items: groups.setdefault(item.machine, []).append(item) context['groups'] = list(groups.items()) context['printed_at'] = timezone.now() context['end_date'] = end_date or '' print_date_raw = end_date or start_date print_date = None if isinstance(print_date_raw, str) and print_date_raw: try: print_date = datetime.strptime(print_date_raw, '%Y-%m-%d').date() except ValueError: print_date = None context['print_date'] = print_date if start_date and end_date and start_date == end_date: context['date_label'] = start_date elif start_date and end_date: context['date_label'] = f"{start_date} — {end_date}" elif start_date: context['date_label'] = f"c {start_date}" elif end_date: context['date_label'] = f"по {end_date}" else: context['date_label'] = '' return context class PlanningView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/planning.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']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = 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') context['user_role'] = role status = (self.request.GET.get('status') or 'work').strip() allowed = {k for k, _ in Deal.STATUS_CHOICES} if status not in allowed: status = 'work' context['selected_status'] = status context['deals'] = Deal.objects.select_related('company').filter(status=status).order_by('-id') context['companies'] = Company.objects.all().order_by('name') return context class DealPlanningView(LoginRequiredMixin, TemplateView): template_name = 'shiftflow/planning_deal.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']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = 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') context['user_role'] = role deal = get_object_or_404(Deal.objects.select_related('company'), pk=self.kwargs['pk']) context['deal'] = deal tasks = ProductionTask.objects.filter(deal=deal).select_related('material').annotate( done_qty=Coalesce(Sum('items__quantity_fact'), 0), planned_qty=Coalesce( Sum( Case( When(items__status__in=['work', 'leftover'], then=F('items__quantity_plan')), default=Value(0), output_field=IntegerField(), ) ), 0, ), ).annotate( remaining_qty=ExpressionWrapper( F('quantity_ordered') - F('done_qty') - F('planned_qty'), output_field=IntegerField(), ) ).order_by('-id') context['tasks'] = tasks context['machines'] = Machine.objects.all() return context class PlanningAddView(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']: return redirect('planning') task_id = request.POST.get('task_id') machine_id = request.POST.get('machine_id') qty_raw = request.POST.get('quantity_plan') if not (task_id and task_id.isdigit() and machine_id and machine_id.isdigit() and qty_raw and qty_raw.isdigit()): return redirect('planning') qty = int(qty_raw) if qty <= 0: return redirect('planning') Item.objects.create( task_id=int(task_id), machine_id=int(machine_id), date=timezone.localdate(), quantity_plan=qty, quantity_fact=0, status='work', is_synced_1c=False, ) next_url = request.POST.get('next') or '' if next_url.startswith('/planning/deal/'): return redirect(next_url) return redirect('planning') class ProductionTaskCreateView(LoginRequiredMixin, FormView): template_name = 'shiftflow/task_create.html' form_class = ProductionTaskCreateForm success_url = reverse_lazy('planning') def get_initial(self): initial = super().get_initial() deal_id = self.request.GET.get('deal') if deal_id and str(deal_id).isdigit(): initial['deal'] = int(deal_id) return initial 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']: return redirect('registry') return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = 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') context['user_role'] = role context['companies'] = Company.objects.all().order_by('name') context['material_categories'] = MaterialCategory.objects.all().order_by('name') context['steel_grades'] = SteelGrade.objects.all().order_by('name') return context def form_valid(self, form): task = ProductionTask( deal=form.cleaned_data['deal'], drawing_name=form.cleaned_data.get('drawing_name') or 'Б/ч', size_value=form.cleaned_data['size_value'], material=form.cleaned_data['material'], quantity_ordered=form.cleaned_data['quantity_ordered'], is_bend=form.cleaned_data.get('is_bend') or False, ) if form.cleaned_data.get('drawing_file'): task.drawing_file = form.cleaned_data['drawing_file'] if form.cleaned_data.get('extra_drawing'): task.extra_drawing = form.cleaned_data['extra_drawing'] task.save() return super().form_valid(form) class DealDetailView(LoginRequiredMixin, View): def get(self, request, pk, *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']: return JsonResponse({'error': 'forbidden'}, status=403) deal = get_object_or_404(Deal, pk=pk) return JsonResponse({ 'id': deal.id, 'number': deal.number, 'status': deal.status, 'company_id': deal.company_id, 'description': deal.description or '', }) class DealUpsertView(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']: return JsonResponse({'error': 'forbidden'}, status=403) deal_id = request.POST.get('id') number = (request.POST.get('number') or '').strip() description = (request.POST.get('description') or '').strip() company_id = request.POST.get('company_id') status = (request.POST.get('status') or 'work').strip() if not number: return JsonResponse({'error': 'number_required'}, status=400) if deal_id and str(deal_id).isdigit(): deal = get_object_or_404(Deal, pk=int(deal_id)) deal.number = number else: deal, _ = Deal.objects.get_or_create(number=number) allowed = {k for k, _ in Deal.STATUS_CHOICES} if status not in allowed: status = 'work' deal.status = status deal.description = description if company_id and str(company_id).isdigit(): deal.company_id = int(company_id) else: deal.company_id = None deal.save() return JsonResponse({'id': deal.id, 'label': deal.number}) class MaterialDetailView(LoginRequiredMixin, View): def get(self, request, pk, *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']: return JsonResponse({'error': 'forbidden'}, status=403) material = get_object_or_404(Material, pk=pk) return JsonResponse({ 'id': material.id, 'category_id': material.category_id, 'steel_grade_id': material.steel_grade_id, 'name': material.name, 'full_name': material.full_name, }) class MaterialUpsertView(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']: return JsonResponse({'error': 'forbidden'}, status=403) material_id = request.POST.get('id') category_id = request.POST.get('category_id') steel_grade_id = request.POST.get('steel_grade_id') name = (request.POST.get('name') or '').strip() if not (category_id and str(category_id).isdigit() and name): return JsonResponse({'error': 'invalid'}, status=400) if material_id and str(material_id).isdigit(): material = get_object_or_404(Material, pk=int(material_id)) else: material = Material() material.category_id = int(category_id) material.name = name if steel_grade_id and str(steel_grade_id).isdigit(): material.steel_grade_id = int(steel_grade_id) else: material.steel_grade_id = None material.save() return JsonResponse({'id': material.id, 'label': material.full_name}) class CompanyUpsertView(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']: return JsonResponse({'error': 'forbidden'}, status=403) company_id = request.POST.get('id') name = (request.POST.get('name') or '').strip() description = (request.POST.get('description') or '').strip() if not name: return JsonResponse({'error': 'name_required'}, status=400) if company_id and str(company_id).isdigit(): company = get_object_or_404(Company, pk=int(company_id)) company.name = name else: company, _ = Company.objects.get_or_create(name=name) company.description = description company.save() return JsonResponse({'id': company.id, 'label': company.name}) class MaterialCategoryUpsertView(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']: return JsonResponse({'error': 'forbidden'}, status=403) category_id = request.POST.get('id') name = (request.POST.get('name') or '').strip() gost_standard = (request.POST.get('gost_standard') or '').strip() if not name: return JsonResponse({'error': 'name_required'}, status=400) if category_id and str(category_id).isdigit(): category = get_object_or_404(MaterialCategory, pk=int(category_id)) category.name = name else: category, _ = MaterialCategory.objects.get_or_create(name=name) category.gost_standard = gost_standard category.save() return JsonResponse({'id': category.id, 'label': category.name}) class SteelGradeUpsertView(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']: return JsonResponse({'error': 'forbidden'}, status=403) grade_id = request.POST.get('id') name = (request.POST.get('name') or '').strip() gost_standard = (request.POST.get('gost_standard') or '').strip() if not name: return JsonResponse({'error': 'name_required'}, status=400) if grade_id and str(grade_id).isdigit(): grade = get_object_or_404(SteelGrade, pk=int(grade_id)) grade.name = name else: grade, _ = SteelGrade.objects.get_or_create(name=name) grade.gost_standard = gost_standard grade.save() return JsonResponse({'id': grade.id, 'label': grade.name}) # Вьюха детального вида и редактирования class ItemUpdateView(LoginRequiredMixin, UpdateView): model = Item template_name = 'shiftflow/item_detail.html' # Перечисляем поля, которые можно редактировать в сменке fields = [ 'machine', 'quantity_plan', 'quantity_fact', 'status', 'is_synced_1c', 'material_taken', 'usable_waste', 'scrap_weight' ] context_object_name = 'item' def get_context_data(self, **kwargs): context = 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') context['user_role'] = role context['machines'] = Machine.objects.all() return context def post(self, request, *args, **kwargs): self.object = self.get_object() profile = getattr(request.user, 'profile', None) role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator') if role in ['admin', 'technologist']: machine_id = request.POST.get('machine') if machine_id and machine_id.isdigit(): self.object.machine_id = int(machine_id) date_value = request.POST.get('date') if date_value: self.object.date = date_value quantity_plan = request.POST.get('quantity_plan') if quantity_plan and quantity_plan.isdigit(): self.object.quantity_plan = int(quantity_plan) quantity_fact = request.POST.get('quantity_fact') if quantity_fact and quantity_fact.isdigit(): self.object.quantity_fact = int(quantity_fact) status = request.POST.get('status') allowed_statuses = {k for k, _ in self.object.STATUS_CHOICES} if status in allowed_statuses: self.object.status = status 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 self.object.save() return redirect('registry') if role in ['operator', 'master']: if self.object.status != 'work': return redirect('registry') 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() errors = [] if not material_taken: errors.append('Заполни поле "Взятый материал"') if not usable_waste: errors.append('Заполни поле "Остаток ДО"') if scrap_weight_raw == '': errors.append('Заполни поле "Лом (кг)" (можно 0)') scrap_weight = None if scrap_weight_raw != '': try: scrap_weight = float(scrap_weight_raw) except ValueError: errors.append('Поле "Лом (кг)" должно быть числом') status = request.POST.get('status', self.object.status) 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 status == 'done': self.object.quantity_fact = self.object.quantity_plan self.object.status = 'done' self.object.save() return redirect('registry') if status == '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('registry') return redirect('registry') if role == 'clerk': if self.object.status not in ['done', 'partial']: return redirect('registry') self.object.is_synced_1c = bool(request.POST.get('is_synced_1c')) self.object.save(update_fields=['is_synced_1c']) return redirect('registry') return redirect('registry') def get_success_url(self): return reverse_lazy('registry')