diff --git a/shiftflow/forms.py b/shiftflow/forms.py new file mode 100644 index 0000000..851d6cd --- /dev/null +++ b/shiftflow/forms.py @@ -0,0 +1,29 @@ +from django import forms + +from warehouse.models import Material + +from .models import Deal + + +class ProductionTaskCreateForm(forms.Form): + drawing_name = forms.CharField(label="Наименование детали", max_length=255, required=False) + quantity_ordered = forms.IntegerField(label="Требуется (шт)", min_value=1) + size_value = forms.FloatField(label="Размер (мм)", min_value=0) + is_bend = forms.BooleanField(label="Гибка", required=False) + + drawing_file = forms.FileField(label="Исходник (DXF/IGES)", required=False) + extra_drawing = forms.FileField(label="Доп. чертеж (PDF)", required=False) + + deal = forms.ModelChoiceField( + label="Сделка", + queryset=Deal.objects.all().order_by("number"), + required=True, + empty_label="— выбрать —", + ) + + material = forms.ModelChoiceField( + label="Материал", + queryset=Material.objects.all().order_by("full_name"), + required=True, + empty_label="— выбрать —", + ) \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/planning.html b/shiftflow/templates/shiftflow/planning.html new file mode 100644 index 0000000..8d1cb7f --- /dev/null +++ b/shiftflow/templates/shiftflow/planning.html @@ -0,0 +1,122 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Планирование

+ + Добавить + +
+ +
+
+ + + + + + + + + + + + + + + + {% for t in tasks %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
СделкаДетальМатериалРазмерНадоСделаноВ планеОсталосьДействия
{{ t.deal.number }}{{ t.drawing_name|default:"Б/ч" }}{{ t.material.full_name|default:t.material.name }}{{ t.size_value }}{{ t.quantity_ordered }}{{ t.done_qty }}{{ t.planned_qty }}{{ t.remaining_qty }} + +
Заданий не найдено
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/task_create.html b/shiftflow/templates/shiftflow/task_create.html new file mode 100644 index 0000000..e3c6e6a --- /dev/null +++ b/shiftflow/templates/shiftflow/task_create.html @@ -0,0 +1,584 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+

Новое задание

+ + Назад + +
+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for e in form.non_field_errors %}
{{ e }}
{% endfor %} +
+ {% endif %} + +
+
+
+
+ Деталь +
+
+
+
+ + {{ form.drawing_name }} + {% for e in form.drawing_name.errors %}
{{ e }}
{% endfor %} +
+
+ + {{ form.quantity_ordered }} + {% for e in form.quantity_ordered.errors %}
{{ e }}
{% endfor %} +
+
+ + {{ form.size_value }} + {% for e in form.size_value.errors %}
{{ e }}
{% endfor %} +
+
+
+ {{ form.is_bend }} + +
+
+
+
+
+
+ +
+
+
+ Файлы +
+
+
+
+ + {{ form.drawing_file }} + +
+
+ + {{ form.extra_drawing }} + +
+
+
+
+
+ +
+
+
+ Сделка и материал +
+
+
+
+ +
+
{{ form.deal }}
+ + +
+ {% for e in form.deal.errors %}
{{ e }}
{% endfor %} +
+ +
+ +
+
{{ form.material }}
+ + +
+ {% for e in form.material.errors %}
{{ e }}
{% endfor %} +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ + + + + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 4c4e4c1..dd404d9 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -1,9 +1,19 @@ from django.urls import path from .views import ( + CompanyUpsertView, + DealDetailView, + DealUpsertView, IndexView, ItemUpdateView, - RegistryView, + MaterialCategoryUpsertView, + MaterialDetailView, + MaterialUpsertView, + PlanningAddView, + PlanningView, + ProductionTaskCreateView, RegistryPrintView, + RegistryView, + SteelGradeUpsertView, ) urlpatterns = [ @@ -12,6 +22,17 @@ urlpatterns = [ # Реестр path('registry/', RegistryView.as_view(), name='registry'), + # Планирование + path('planning/', PlanningView.as_view(), name='planning'), + path('planning/add/', PlanningAddView.as_view(), name='planning_add'), + path('planning/task/add/', ProductionTaskCreateView.as_view(), name='task_add'), + path('planning/deal//json/', DealDetailView.as_view(), name='deal_json'), + path('planning/deal/upsert/', DealUpsertView.as_view(), name='deal_upsert'), + path('planning/company/upsert/', CompanyUpsertView.as_view(), name='company_upsert'), + path('planning/material//json/', MaterialDetailView.as_view(), name='material_json'), + path('planning/material/upsert/', MaterialUpsertView.as_view(), name='material_upsert'), + path('planning/material-category/upsert/', MaterialCategoryUpsertView.as_view(), name='material_category_upsert'), + path('planning/steel-grade/upsert/', SteelGradeUpsertView.as_view(), name='steel_grade_upsert'), # Печать сменного листа path('registry/print/', RegistryPrintView.as_view(), name='registry_print'), path('item//', ItemUpdateView.as_view(), name='item_detail'), diff --git a/shiftflow/views.py b/shiftflow/views.py index 908fbf8..211d7ea 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -1,11 +1,19 @@ from datetime import datetime -from django.shortcuts import redirect +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.generic import TemplateView, ListView, UpdateView +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 .models import Item, Machine + +from warehouse.models import Material, MaterialCategory, SteelGrade + +from .forms import ProductionTaskCreateForm +from .models import Company, Deal, Item, Machine, ProductionTask # Класс главной страницы (роутер) class IndexView(TemplateView): @@ -208,6 +216,288 @@ class RegistryPrintView(LoginRequiredMixin, TemplateView): 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 + + tasks = ProductionTask.objects.select_related('deal', '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(), + ) + ) + + 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, + ) + + return redirect('planning') + + +class ProductionTaskCreateView(LoginRequiredMixin, FormView): + template_name = 'shiftflow/task_create.html' + form_class = ProductionTaskCreateForm + success_url = reverse_lazy('planning') + + 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, + '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') + + 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) + + 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 diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index 5d90a71..f1c9467 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -16,7 +16,9 @@ {% if user_role in 'admin,technologist' %} - + {% endif %} {% if user_role in 'admin,technologist,master,operator' %}