From 49e9080d0e020b37e4ebabce971741ba7ffaced8 Mon Sep 17 00:00:00 2001 From: ackFromRedmi Date: Tue, 14 Apr 2026 07:27:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=83=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8,=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B9.=20=D0=9E=D1=80=D0=B3?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BB=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shiftflow/services/bom_explosion.py | 76 ++- shiftflow/services/shipping.py | 285 ++++++++++ .../shiftflow/partials/_workitems_table.html | 4 + .../templates/shiftflow/planning_deal.html | 278 +++++++--- .../shiftflow/product_info_assembly.html | 305 +++++++---- .../shiftflow/product_info_casting.html | 231 +++++--- .../shiftflow/product_info_outsourced.html | 255 ++++++--- .../shiftflow/product_info_part.html | 255 ++++++--- .../shiftflow/product_info_purchased.html | 231 +++++--- shiftflow/templates/shiftflow/shipping.html | 182 +++++++ .../templates/shiftflow/warehouse_stocks.html | 15 +- shiftflow/urls.py | 2 + shiftflow/views.py | 495 +++++++++++++++--- templates/components/_navbar.html | 6 + 14 files changed, 2056 insertions(+), 564 deletions(-) create mode 100644 shiftflow/services/shipping.py create mode 100644 shiftflow/templates/shiftflow/shipping.html diff --git a/shiftflow/services/bom_explosion.py b/shiftflow/services/bom_explosion.py index aefd4bd..eedb415 100644 --- a/shiftflow/services/bom_explosion.py +++ b/shiftflow/services/bom_explosion.py @@ -436,4 +436,78 @@ def explode_roots_additive( skipped_no_material, skipped_supply, ) - return ExplosionStats(tasks_created, tasks_updated, 0, 0) \ No newline at end of file + return ExplosionStats(tasks_created, tasks_updated, 0, 0) + + +@transaction.atomic +def rollback_roots_additive( + deal_id: int, + roots: list[tuple[int, int]], +) -> ExplosionStats: + """Откат additive BOM Explosion. + + Используется для сценария "запустили в производство, но в смену ещё не поставили": + - уменьшает started_qty у строки партии (делается во вьюхе) + - уменьшает quantity_ordered у ProductionTask по всем узлам BOM пропорционально откату + + Ограничение: откат должен быть запрещён, если по сущности уже есть план/факт в WorkItem. + """ + deal = Deal.objects.select_for_update().get(pk=deal_id) + + roots = [(int(eid), int(q)) for eid, q in (roots or []) if int(q or 0) > 0] + if not roots: + return ExplosionStats(0, 0, 0, 0) + + root_ids = {eid for eid, _ in roots} + adjacency = _build_bom_graph(root_ids) + + required_nodes: dict[int, int] = {} + for root_entity_id, root_qty in roots: + _accumulate_requirements(int(root_entity_id), int(root_qty), adjacency, set(), required_nodes) + + entities = { + e.id: e + for e in ProductEntity.objects.select_related('planned_material', 'planned_material__category') + .filter(id__in=list(required_nodes.keys())) + } + + tasks_updated = 0 + skipped_supply = 0 + missing_tasks = 0 + + for entity_id, qty in required_nodes.items(): + entity = entities.get(int(entity_id)) + if not entity: + continue + + et = (entity.entity_type or '').strip() + if et in ['purchased', 'casting', 'outsourced']: + skipped_supply += 1 + continue + + pt = ProductionTask.objects.filter(deal=deal, entity=entity).first() + if not pt: + missing_tasks += 1 + continue + + old = int(pt.quantity_ordered or 0) + new_qty = old - int(qty) + if new_qty < 0: + new_qty = 0 + + if new_qty != old: + pt.quantity_ordered = int(new_qty) + pt.save(update_fields=['quantity_ordered']) + tasks_updated += 1 + + logger.info( + 'rollback_roots_additive: deal_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s', + deal_id, + roots, + len(required_nodes), + tasks_updated, + skipped_supply, + missing_tasks, + ) + + return ExplosionStats(0, tasks_updated, 0, 0) \ No newline at end of file diff --git a/shiftflow/services/shipping.py b/shiftflow/services/shipping.py new file mode 100644 index 0000000..5391e00 --- /dev/null +++ b/shiftflow/services/shipping.py @@ -0,0 +1,285 @@ +import logging +from typing import Dict + +from django.db import transaction +from django.db.models import Sum +from django.db.models.functions import Coalesce +from django.utils import timezone + +from manufacturing.models import EntityOperation +from shiftflow.models import DealItem, WorkItem +from warehouse.models import Material, StockItem, TransferLine, TransferRecord +from warehouse.services.transfers import receive_transfer + +logger = logging.getLogger('mes') + + +def build_shipment_rows( + *, + deal_id: int, + shipping_location_id: int, +) -> tuple[list[dict], list[dict]]: + """ + Формирует данные для интерфейса отгрузки по сделке: + - Список деталей (Entities), готовых к отгрузке и уже отгруженных. + - Список давальческого сырья (Materials), доступного для возврата/отгрузки. + """ + deal_items = list( + DealItem.objects.select_related('entity') + .filter(deal_id=int(deal_id)) + .order_by('entity__entity_type', 'entity__drawing_number', 'entity__name', 'id') + ) + + entities = [it.entity for it in deal_items if it.entity_id and it.entity] + ent_ids = [int(e.id) for e in entities if e] + + entity_ops = list( + EntityOperation.objects.select_related('operation') + .filter(entity_id__in=ent_ids) + .order_by('entity_id', 'seq', 'id') + ) + + route_codes: dict[int, list[str]] = {} + last_code: dict[int, str] = {} + + for eo in entity_ops: + if not eo.operation_id or not eo.operation: + continue + code = (eo.operation.code or '').strip() + if not code: + continue + route_codes.setdefault(int(eo.entity_id), []).append(code) + + # Определяем последнюю операцию в маршруте для каждой детали + for eid, codes in route_codes.items(): + if codes: + last_code[int(eid)] = str(codes[-1]) + + wi_qs = WorkItem.objects.select_related('operation').filter(deal_id=int(deal_id), entity_id__in=ent_ids) + + done_by: dict[tuple[int, str], int] = {} + done_total_by_entity: dict[int, int] = {} + + # Подсчитываем количество выполненных деталей по операциям + for wi in wi_qs: + op_code = '' + if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None): + op_code = (wi.operation.code or '').strip() + if not op_code: + op_code = (wi.stage or '').strip() + if not op_code: + continue + + eid = int(wi.entity_id) + done = int(wi.quantity_done or 0) + done_by[(eid, str(op_code))] = done_by.get((eid, str(op_code)), 0) + done + done_total_by_entity[eid] = done_total_by_entity.get(eid, 0) + done + + shipped_by = { + int(r['entity_id']): float(r['s'] or 0.0) + for r in StockItem.objects.filter( + is_archived=False, + location_id=int(shipping_location_id), + deal_id=int(deal_id), + entity_id__in=ent_ids, + ) + .values('entity_id') + .annotate(s=Coalesce(Sum('quantity'), 0.0)) + } + + ent_avail = { + int(r['entity_id']): float(r['s'] or 0.0) + for r in StockItem.objects.filter( + deal_id=int(deal_id), + is_archived=False, + quantity__gt=0, + entity_id__in=ent_ids, + ) + .exclude(location_id=int(shipping_location_id)) + .values('entity_id') + .annotate(s=Coalesce(Sum('quantity'), 0.0)) + } + + entity_rows = [] + for di in deal_items: + e = di.entity + if not e: + continue + + need = int(di.quantity or 0) + if need <= 0: + continue + + eid = int(e.id) + last = last_code.get(eid) + + # Количество готовых деталей: берем по последней операции маршрута, + # либо общее количество, если маршрут не задан + ready_done = int(done_by.get((eid, str(last)), 0) or 0) if last else int(done_total_by_entity.get(eid, 0) or 0) + ready_val = min(need, ready_done) + + # Сколько уже отгружено на склад отгрузки + shipped_val = int(shipped_by.get(eid, 0.0) or 0.0) + shipped_val = min(need, shipped_val) + + remaining_ready = int(max(0, ready_val - shipped_val)) + if remaining_ready <= 0: + continue + + entity_rows.append({ + 'entity': e, + 'available': float(ent_avail.get(eid, 0.0) or 0.0), + 'ready': int(ready_val), + 'shipped': int(shipped_val), + 'remaining_ready': int(remaining_ready), + }) + + mat_rows = list( + StockItem.objects.filter( + deal_id=int(deal_id), + is_archived=False, + quantity__gt=0, + material_id__isnull=False, + is_customer_supplied=True, + ) + .exclude(location_id=int(shipping_location_id)) + .values('material_id') + .annotate(available=Coalesce(Sum('quantity'), 0.0)) + .order_by('material_id') + ) + + mat_ids = [int(r['material_id']) for r in mat_rows if r.get('material_id')] + mats = {m.id: m for m in Material.objects.filter(id__in=mat_ids)} + material_rows = [] + for r in mat_rows: + mid = int(r['material_id']) + m = mats.get(mid) + if not m: + continue + material_rows.append({ + 'material': m, + 'available': float(r.get('available') or 0.0), + }) + + return entity_rows, material_rows + + +@transaction.atomic +def create_shipment_transfers( + *, + deal_id: int, + shipping_location_id: int, + entity_qty: Dict[int, int], + material_qty: Dict[int, float], + user_id: int, +) -> list[int]: + """ + Создает документы перемещения (TransferRecord) на склад отгрузки + для указанных деталей и давальческого сырья. + """ + logger.info( + 'fn:start create_shipment_transfers deal_id=%s shipping_location_id=%s user_id=%s', + deal_id, shipping_location_id, user_id + ) + now = timezone.now() + + transfers_by_location: dict[int, TransferRecord] = {} + + def get_transfer(from_location_id: int) -> TransferRecord: + tr = transfers_by_location.get(int(from_location_id)) + if tr: + return tr + + tr = TransferRecord.objects.create( + from_location_id=int(from_location_id), + to_location_id=int(shipping_location_id), + sender_id=int(user_id), + receiver_id=int(user_id), + occurred_at=now, + status='received', + received_at=now, + is_applied=False, + ) + transfers_by_location[int(from_location_id)] = tr + return tr + + def alloc_stock_lines(qs, need_qty: float) -> None: + """ + Резервирует необходимое количество из доступных складских остатков. + Использует select_for_update для предотвращения гонок данных. + """ + remaining = float(need_qty) + if remaining <= 0: + return + + items = list(qs.select_for_update().order_by('location_id', 'created_at', 'id')) + for si in items: + if remaining <= 0: + break + + if int(si.location_id) == int(shipping_location_id): + continue + + si_qty = float(si.quantity or 0.0) + if si_qty <= 0: + continue + + if si.unique_id: + if remaining < si_qty: + raise ValueError('Нельзя частично отгружать позицию с маркировкой (unique_id).') + take = si_qty + else: + take = min(remaining, si_qty) + + tr = get_transfer(int(si.location_id)) + TransferLine.objects.create(transfer=tr, stock_item=si, quantity=float(take)) + remaining -= float(take) + + if remaining > 0: + raise ValueError('Недостаточно количества на складах для отгрузки.') + + for ent_id, qty in (entity_qty or {}).items(): + q = int(qty or 0) + if q <= 0: + continue + + alloc_stock_lines( + StockItem.objects.filter( + deal_id=int(deal_id), + is_archived=False, + quantity__gt=0, + entity_id=int(ent_id), + ).select_related('entity', 'location'), + float(q), + ) + + for mat_id, qty in (material_qty or {}).items(): + q = float(qty or 0.0) + if q <= 0: + continue + + alloc_stock_lines( + StockItem.objects.filter( + deal_id=int(deal_id), + is_archived=False, + quantity__gt=0, + material_id=int(mat_id), + is_customer_supplied=True, + ).select_related('material', 'location'), + float(q), + ) + + ids = [] + for tr in transfers_by_location.values(): + receive_transfer(int(tr.id), int(user_id)) + ids.append(int(tr.id)) + + ids.sort() + + logger.info( + 'fn:done create_shipment_transfers deal_id=%s transfers=%s', + deal_id, + ids, + ) + + return ids \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/partials/_workitems_table.html b/shiftflow/templates/shiftflow/partials/_workitems_table.html index 8aaa12e..6149180 100644 --- a/shiftflow/templates/shiftflow/partials/_workitems_table.html +++ b/shiftflow/templates/shiftflow/partials/_workitems_table.html @@ -6,6 +6,7 @@ Дата Сделка Цех/Пост + Операция Наименование Материал Файлы @@ -28,6 +29,9 @@ {% endif %} + + {{ wi.operation.name|default:wi.stage|default:"—" }} + {{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }} diff --git a/shiftflow/templates/shiftflow/planning_deal.html b/shiftflow/templates/shiftflow/planning_deal.html index 9949438..4ebfc85 100644 --- a/shiftflow/templates/shiftflow/planning_deal.html +++ b/shiftflow/templates/shiftflow/planning_deal.html @@ -40,7 +40,13 @@ {% endif %} - {% if user_role in 'admin,technologist' %} + {% if user_role in 'admin,clerk,manager,prod_head,technologist' %} + + Отгрузка + + {% endif %} + + {% if user_role in 'admin,technologist,manager,prod_head' %} @@ -65,7 +71,7 @@ Прогресс Заказано / Сделано / В плане Осталось - В производство + Действия @@ -88,20 +94,46 @@ {{ it.remaining_qty }} - {% if user_role in 'admin,technologist' %} - - {% else %} - - {% endif %} +
+ {% if user_role in 'admin,technologist,manager,prod_head' %} + + +
+ {% csrf_token %} + + + + + + +
+ + + {% else %} + + {% endif %} +
{% empty %} @@ -182,6 +214,20 @@ + {% if user_role in 'admin,technologist' %} + {% if bi.started_qty and bi.started_qty > 0 %} + + {% endif %} + {% endif %} + {% if user_role in 'admin,technologist' and not b.is_default %}
@@ -202,6 +248,39 @@ {% else %}
Пусто
{% endif %} + + {% for bi in b.items_list %} + {% if user_role in 'admin,technologist' and bi.started_qty and bi.started_qty > 0 %} +
+ {% endif %} + {% endfor %} {% if user_role in 'admin,technologist' and not b.is_default %} @@ -526,14 +605,19 @@
-
+ {% csrf_token %}
-
+
- +
{{ entity.get_entity_type_display }}
-
+
-
- -
- - -
-
- -
+
-
- - - {% if entity.pdf_main %} - - {% endif %} -
- - {% if not can_edit %} -
- -
- {% if entity_ops %} - {% for eo in entity_ops %} - {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} - {% endfor %} - {% else %} - — не указан — - {% endif %} -
-
- {% endif %} - -
- +
+
- - -
-
- -
- -
- - + +
@@ -114,6 +75,45 @@
+
+ + + {% if entity.pdf_main %} + + {% endif %} +
+ +
+ + + {% if entity.dxf_file %} + + {% endif %} +
+ +
+ + + {% if entity.preview %} + + {% endif %} +
+ + {% if not can_edit %} +
+ +
+ {% if entity_ops %} + {% for eo in entity_ops %} + {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} + {% endfor %} + {% else %} + — не указан — + {% endif %} +
+
+ {% endif %} +
@@ -128,7 +128,7 @@ {% if can_edit %} -
+
Операции техпроцесса
@@ -137,69 +137,154 @@ № Операция - Цех - + Действия - - {% for eo in entity_ops %} - - {{ eo.seq }} - {{ eo.operation.name }} - {% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - -
-
- - - {% empty %} - Операции не добавлены - {% endfor %} + + {% if selected_operation_ids %} + {% for op_id in selected_operation_ids %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% else %} + {% for i in '1234'|make_list %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% endif %}
-
- {% csrf_token %} - - +
+ + +
-
- - -
-
- -
-
+
+ + + +
{% endif %} @@ -290,6 +375,7 @@ Тип Обозначение Наименование + Заполнено Кол-во @@ -300,6 +386,13 @@ {{ ln.child.get_entity_type_display }} {{ ln.child.drawing_number|default:"—" }} {{ ln.child.name }} + + {% if ln.child.passport_filled %} + Да + {% else %} + Нет + {% endif %} +
{% csrf_token %} @@ -325,7 +418,7 @@ {% empty %} - Пока нет компонентов + Пока нет компонентов {% endfor %} diff --git a/shiftflow/templates/shiftflow/product_info_casting.html b/shiftflow/templates/shiftflow/product_info_casting.html index b34ed96..b9b0766 100644 --- a/shiftflow/templates/shiftflow/product_info_casting.html +++ b/shiftflow/templates/shiftflow/product_info_casting.html @@ -25,24 +25,29 @@
- + {% csrf_token %}
-
+
- +
{{ entity.get_entity_type_display }}
-
+
-
+
+ + +
+ +
@@ -50,11 +55,6 @@
-
- - -
-
@@ -80,7 +80,7 @@
{% endif %} -
+
{% if entity.pdf_main %} @@ -88,7 +88,15 @@ {% endif %}
-
+
+ + + {% if entity.dxf_file %} + + {% endif %} +
+ +
{% if entity.preview %} @@ -105,7 +113,7 @@ {% if can_edit %} -
+
Операции техпроцесса
@@ -114,69 +122,154 @@ № Операция - Цех - + Действия - - {% for eo in entity_ops %} - - {{ eo.seq }} - {{ eo.operation.name }} - {% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - -
-
- - - {% empty %} - Операции не добавлены - {% endfor %} + + {% if selected_operation_ids %} + {% for op_id in selected_operation_ids %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% else %} + {% for i in '1234'|make_list %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% endif %}
-
- {% csrf_token %} - - +
+ + +
-
- - -
-
- -
-
+
+ + + +
{% endif %}
diff --git a/shiftflow/templates/shiftflow/product_info_outsourced.html b/shiftflow/templates/shiftflow/product_info_outsourced.html index f7a987e..49cec3f 100644 --- a/shiftflow/templates/shiftflow/product_info_outsourced.html +++ b/shiftflow/templates/shiftflow/product_info_outsourced.html @@ -25,24 +25,29 @@
-
+ {% csrf_token %}
-
+
- +
{{ entity.get_entity_type_display }}
-
+
-
+
+ + +
+ +
@@ -50,13 +55,32 @@
-
- - +
+ + + {% if entity.pdf_main %} + + {% endif %} +
+ +
+ + + {% if entity.dxf_file %} + + {% endif %} +
+ +
+ + + {% if entity.preview %} + + {% endif %}
{% if not can_edit %} -
+
{% if entity_ops %} @@ -70,12 +94,9 @@
{% endif %} -
- - - {% if entity.pdf_main %} - - {% endif %} +
+ +
@@ -83,11 +104,6 @@
-
- - -
-
{% if can_edit %} @@ -97,7 +113,7 @@ {% if can_edit %} -
+
Операции техпроцесса
@@ -106,69 +122,154 @@ № Операция - Цех - + Действия - - {% for eo in entity_ops %} - - {{ eo.seq }} - {{ eo.operation.name }} - {% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - -
-
- - - {% empty %} - Операции не добавлены - {% endfor %} + + {% if selected_operation_ids %} + {% for op_id in selected_operation_ids %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% else %} + {% for i in '1234'|make_list %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% endif %}
-
- {% csrf_token %} - - +
+ + +
-
- - -
-
- -
-
+
+ + + +
{% endif %}
diff --git a/shiftflow/templates/shiftflow/product_info_part.html b/shiftflow/templates/shiftflow/product_info_part.html index 57e6883..7168030 100644 --- a/shiftflow/templates/shiftflow/product_info_part.html +++ b/shiftflow/templates/shiftflow/product_info_part.html @@ -25,24 +25,29 @@
-
+ {% csrf_token %}
-
+
- +
{{ entity.get_entity_type_display }}
-
+
-
+
+ + +
+ +
@@ -50,11 +55,6 @@
-
- - -
-
- {% if not can_edit %} -
- -
- {% if entity_ops %} - {% for eo in entity_ops %} - {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} - {% endfor %} - {% else %} - — не указан — - {% endif %} -
-
- {% endif %} -
@@ -105,7 +90,22 @@
+ {% if not can_edit %}
+ +
+ {% if entity_ops %} + {% for eo in entity_ops %} + {{ eo.seq }}. {{ eo.operation.name }}{% if eo.operation.workshop %} ({{ eo.operation.workshop.name }}){% endif %}{% if not forloop.last %} · {% endif %} + {% endfor %} + {% else %} + — не указан — + {% endif %} +
+
+ {% endif %} + +
{% if entity.pdf_main %} @@ -113,15 +113,15 @@ {% endif %}
-
+
- + {% if entity.dxf_file %} {% endif %}
-
+
{% if entity.preview %} @@ -148,7 +148,7 @@ {% if can_edit %} -
+
Операции техпроцесса
@@ -157,69 +157,154 @@ № Операция - Цех - + Действия - - {% for eo in entity_ops %} - - {{ eo.seq }} - {{ eo.operation.name }} - {% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - -
-
- - - {% empty %} - Операции не добавлены - {% endfor %} + + {% if selected_operation_ids %} + {% for op_id in selected_operation_ids %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% else %} + {% for i in '1234'|make_list %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% endif %}
-
- {% csrf_token %} - - +
+ + +
-
- - -
-
- -
-
+
+ + + +
{% endif %}
diff --git a/shiftflow/templates/shiftflow/product_info_purchased.html b/shiftflow/templates/shiftflow/product_info_purchased.html index e927902..1f6ba27 100644 --- a/shiftflow/templates/shiftflow/product_info_purchased.html +++ b/shiftflow/templates/shiftflow/product_info_purchased.html @@ -25,24 +25,29 @@
-
+ {% csrf_token %}
-
+
- +
{{ entity.get_entity_type_display }}
-
+
-
+
+ + +
+ +
@@ -50,11 +55,6 @@
-
- - -
-
@@ -75,7 +75,7 @@
{% endif %} -
+
{% if entity.pdf_main %} @@ -83,7 +83,15 @@ {% endif %}
-
+
+ + + {% if entity.dxf_file %} + + {% endif %} +
+ +
{% if entity.preview %} @@ -100,7 +108,7 @@ {% if can_edit %} -
+
Операции техпроцесса
@@ -109,69 +117,154 @@ № Операция - Цех - + Действия - - {% for eo in entity_ops %} - - {{ eo.seq }} - {{ eo.operation.name }} - {% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %} - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - - -
-
- {% csrf_token %} - - - - -
-
- - - {% empty %} - Операции не добавлены - {% endfor %} + + {% if selected_operation_ids %} + {% for op_id in selected_operation_ids %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% else %} + {% for i in '1234'|make_list %} + + {{ forloop.counter }} + + + + +
+ + + +
+ + + {% endfor %} + {% endif %}
-
- {% csrf_token %} - - +
+ + +
-
- - -
-
- -
-
+
+ + + +
{% endif %}
diff --git a/shiftflow/templates/shiftflow/shipping.html b/shiftflow/templates/shiftflow/shipping.html new file mode 100644 index 0000000..1d33103 --- /dev/null +++ b/shiftflow/templates/shiftflow/shipping.html @@ -0,0 +1,182 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Отгрузка

+ Назад +
+ +
+
+
+ + + +
+ +
+ +
+
+ + {% if selected_deal_id %} +
+ +
+ {% csrf_token %} + + +
Позиции к отгрузке (по сделке)
+ +
+ + + + + + + + + + {% for r in entity_rows %} + + + + + + {% empty %} + + {% endfor %} + +
ПозицияЕсть на складеК отгрузке
+
{{ r.entity.drawing_number|default:"—" }} {{ r.entity.name }}
+
{{ r.entity.get_entity_type_display }}
+
{{ r.available }} + +
Позиции сделки не найдены
+
+ + {% if material_rows %} +
Давальческий материал
+
+ + + + + + + + + + {% for r in material_rows %} + + + + + + {% endfor %} + +
МатериалЕсть на складеК отгрузке
{{ r.material.full_name|default:r.material.name }}{{ r.available }} + +
+
+ {% endif %} + +
+ {% if can_edit %} + + {% else %} + + {% endif %} +
+ + +
+ + + {% else %} +
Выбери сделку, чтобы сформировать список к отгрузке.
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/warehouse_stocks.html b/shiftflow/templates/shiftflow/warehouse_stocks.html index 4b83f2b..7887015 100644 --- a/shiftflow/templates/shiftflow/warehouse_stocks.html +++ b/shiftflow/templates/shiftflow/warehouse_stocks.html @@ -169,20 +169,7 @@ Переместить - +
{% else %} только просмотр diff --git a/shiftflow/urls.py b/shiftflow/urls.py index c7a86f7..e3f348c 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -57,6 +57,7 @@ from .views import ( WarehouseStocksView, WarehouseTransferCreateView, ProcurementDashboardView, + ShippingView, ) urlpatterns = [ @@ -120,6 +121,7 @@ urlpatterns = [ path('closing/workitems/', ClosingWorkItemsView.as_view(), name='closing_workitems'), path('writeoffs/', WriteOffsView.as_view(), name='writeoffs'), path('procurement/', ProcurementDashboardView.as_view(), name='procurement'), + path('shipping/', ShippingView.as_view(), name='shipping'), path('legacy/closing/', LegacyClosingView.as_view(), name='legacy_closing'), path('legacy/writeoffs/', LegacyWriteOffsView.as_view(), name='legacy_writeoffs'), diff --git a/shiftflow/views.py b/shiftflow/views.py index 9d7a9c7..1120b4b 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -114,6 +114,7 @@ from shiftflow.services.closing import apply_closing, apply_closing_workitems from shiftflow.services.bom_explosion import ( explode_deal, explode_roots_additive, + rollback_roots_additive, ExplosionValidationError, _build_bom_graph, _accumulate_requirements, @@ -2663,30 +2664,34 @@ class WorkItemPlanAddView(LoginRequiredMixin, View): machine.workshop_id if machine and machine.workshop_id else (workshop.id if workshop else getattr(op, 'workshop_id', None)) ) - # Комментарий: Если включен чекбокс recursive_bom, мы бежим по всему дереву BOM вниз - # и создаем WorkItem для ВСЕХ операций маршрута каждого дочернего компонента, - # плюс для выбранной операции родителя. + # Комментарий: Если включен чекбокс recursive_bom, бежим по дереву BOM вниз. + # Для дочерних компонентов создаём WorkItem только на текущую операцию + # (по DealEntityProgress.current_seq), чтобы операции возникали по очереди. + # Для родителя создаём WorkItem по выбранной операции из модалки. if recursive_bom: try: with transaction.atomic(): adjacency = _build_bom_graph({entity_id}) required_nodes = {} _accumulate_requirements(entity_id, qty, adjacency, set(), required_nodes) - - # Получаем все маршруты для собранных сущностей + node_ids = list(required_nodes.keys()) - entity_ops = list(EntityOperation.objects.select_related('operation').filter(entity_id__in=node_ids)) - ops_by_entity = {} - for eo in entity_ops: - ops_by_entity.setdefault(eo.entity_id, []).append(eo) - + + progress_map = { + int(p.entity_id): int(p.current_seq or 1) + for p in DealEntityProgress.objects.filter(deal_id=int(deal_id), entity_id__in=node_ids) + } + + ops_map = { + (int(eo.entity_id), int(eo.seq)): eo + for eo in EntityOperation.objects.select_related('operation', 'operation__workshop') + .filter(entity_id__in=node_ids) + } + created_count = 0 + for c_id, c_qty in required_nodes.items(): - c_ops = ops_by_entity.get(c_id, []) - if c_id == entity_id: - # Для самого родителя мы ставим только ту операцию, которую выбрали в модалке (или тоже все?) - # Пользователь просил "на все операции маршрута для каждой вложенной детали". - # Родительскую мы создадим явно по выбранной, остальные дочерние - по всем. + if int(c_id) == int(entity_id): WorkItem.objects.create( deal_id=deal_id, entity_id=entity_id, @@ -2700,25 +2705,28 @@ class WorkItemPlanAddView(LoginRequiredMixin, View): date=timezone.localdate(), ) created_count += 1 - else: - # Для дочерних создаем на все операции маршрута - for eo in c_ops: - if not eo.operation: - continue - w_id = eo.operation.workshop_id - WorkItem.objects.create( - deal_id=deal_id, - entity_id=c_id, - operation_id=eo.operation_id, - workshop_id=w_id, - machine_id=None, - stage=(eo.operation.name or '')[:32], - quantity_plan=c_qty, - quantity_done=0, - status='planned', - date=timezone.localdate(), - ) - created_count += 1 + continue + + seq = int(progress_map.get(int(c_id), 1) or 1) + eo = ops_map.get((int(c_id), seq)) + if not eo or not getattr(eo, 'operation_id', None) or not getattr(eo, 'operation', None): + continue + + cur_op = eo.operation + WorkItem.objects.create( + deal_id=deal_id, + entity_id=int(c_id), + operation_id=int(cur_op.id), + workshop_id=(int(cur_op.workshop_id) if getattr(cur_op, 'workshop_id', None) else None), + machine_id=None, + stage=(cur_op.name or '')[:32], + quantity_plan=int(c_qty), + quantity_done=0, + status='planned', + date=timezone.localdate(), + ) + created_count += 1 + messages.success(request, f'Рекурсивно добавлено в смену заданий: {created_count} шт.') except Exception as e: logger.exception('workitem_add recursive error') @@ -2989,10 +2997,10 @@ class MaterialCategoryUpsertView(LoginRequiredMixin, View): 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']: + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): return JsonResponse({'error': 'forbidden'}, status=403) grade_id = request.POST.get('id') @@ -3014,34 +3022,60 @@ class SteelGradeUpsertView(LoginRequiredMixin, View): class EntitiesSearchView(LoginRequiredMixin, View): + """JSON-поиск сущностей ProductEntity для модальных окон. + + Использование на фронтенде (пример): + /entities/search/?entity_type=part&q_dn=12.34&q_name=косынка + + Возвращает: + { + "results": [{"id": 1, "type": "part", "drawing_number": "...", "name": "..."}, ...], + "count": 10 + } + + Диагностика: + - логируем start/forbidden/done (без секретов) для разбора кейсов «ничего не находит». + """ + def get(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) + roles = get_user_roles(request.user) # Роли пользователя (Django Groups + fallback на profile.role) + if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): # Доступ только для этих ролей + logger.info('entities_search:forbidden user_id=%s roles=%s', request.user.id, sorted(roles)) + return JsonResponse({'error': 'forbidden'}, status=403) # Для фронта это сигнал показать «нет доступа» - q_dn = (request.GET.get('q_dn') or '').strip() - q_name = (request.GET.get('q_name') or '').strip() - et = (request.GET.get('entity_type') or '').strip() + q_dn = (request.GET.get('q_dn') or '').strip() # Поиск по обозначению (drawing_number), подстрока + q_name = (request.GET.get('q_name') or '').strip() # Поиск по наименованию (name), подстрока + et = (request.GET.get('entity_type') or '').strip() # Фильтр по типу сущности (ProductEntity.entity_type) - qs = ProductEntity.objects.all() - if et in ['product', 'assembly', 'part']: + logger.info('entities_search:start user_id=%s et=%s q_dn=%s q_name=%s', request.user.id, et, q_dn, q_name) + + qs = ProductEntity.objects.all() # Базовая выборка по всем сущностям КД + + # Фильтр по типу включаем для всех допустимых типов ProductEntity. + allowed_types = {'product', 'assembly', 'part', 'purchased', 'casting', 'outsourced'} + if et in allowed_types: qs = qs.filter(entity_type=et) - if q_dn: - qs = qs.filter(drawing_number__icontains=q_dn) - if q_name: - qs = qs.filter(name__icontains=q_name) - data = [ + if q_dn: + qs = qs.filter(drawing_number__icontains=q_dn) # ILIKE по drawing_number + + if q_name: + qs = qs.filter(name__icontains=q_name) # ILIKE по name + + qs = qs.order_by('entity_type', 'drawing_number', 'name', 'id') + + data = [ # Формируем компактный JSON (только то, что нужно для селекта в модалке) { - 'id': e.id, - 'type': e.entity_type, - 'drawing_number': e.drawing_number, - 'name': e.name, + 'id': e.id, # PK сущности + 'type': e.entity_type, # Код типа + 'drawing_number': e.drawing_number, # Обозначение + 'name': e.name, # Наименование } - for e in qs.order_by('entity_type', 'drawing_number', 'name', 'id')[:200] + for e in qs[:200] # Ограничиваем ответ 200 строками ] - return JsonResponse({'results': data}) + + logger.info('entities_search:done user_id=%s count=%s', request.user.id, len(data)) + return JsonResponse({'results': data, 'count': len(data)}) # Отдаём результаты в JSON class DealBatchActionView(LoginRequiredMixin, View): @@ -3184,6 +3218,65 @@ class DealBatchActionView(LoginRequiredMixin, View): messages.success(request, 'Строка удалена.') return redirect(next_url) + if action == 'rollback_batch_item_production': + item_id = parse_int(request.POST.get('item_id')) + qty = parse_int(request.POST.get('quantity')) + if not item_id or not qty or qty <= 0: + messages.error(request, 'Заполни количество.') + return redirect(next_url) + + logger.info('rollback_batch_item_production:start deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) + + try: + with transaction.atomic(): + bi = ( + DealBatchItem.objects.select_for_update() + .select_related('batch', 'entity') + .filter(id=item_id, batch__deal_id=deal_id) + .first() + ) + if not bi: + messages.error(request, 'Строка партии не найдена.') + return redirect(next_url) + + started = int(getattr(bi, 'started_qty', 0) or 0) + if int(qty) > started: + messages.error(request, 'Нельзя откатить больше, чем запущено в этой партии.') + return redirect(next_url) + + # Комментарий: откат запрещён, если хоть что-то из этого запуска уже попало в смену. + adjacency = _build_bom_graph({int(bi.entity_id)}) + required_nodes: dict[int, int] = {} + _accumulate_requirements(int(bi.entity_id), int(qty), adjacency, set(), required_nodes) + affected_ids = list(required_nodes.keys()) + + wi_exists = WorkItem.objects.filter(deal_id=deal_id, entity_id__in=affected_ids).filter( + Q(quantity_plan__gt=0) | Q(quantity_done__gt=0) + ).exists() + if wi_exists: + messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).') + return redirect(next_url) + + stats = rollback_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))]) + + bi.started_qty = started - int(qty) + bi.save(update_fields=['started_qty']) + + messages.success(request, f'Откат выполнен: −{qty} шт. Задачи обновлено: {stats.tasks_updated}.') + logger.info( + 'rollback_batch_item_production:done deal_id=%s item_id=%s entity_id=%s qty=%s tasks_updated=%s', + deal_id, + item_id, + bi.entity_id, + qty, + stats.tasks_updated, + ) + return redirect(next_url) + except Exception: + logger.exception('rollback_batch_item_production:error deal_id=%s item_id=%s qty=%s', deal_id, item_id, qty) + messages.error(request, 'Ошибка отката запуска. Подробности в логе сервера.') + return redirect(next_url) + if action == 'start_batch_item_production': item_id = parse_int(request.POST.get('item_id')) qty = parse_int(request.POST.get('quantity')) @@ -3260,11 +3353,17 @@ class DealBatchActionView(LoginRequiredMixin, View): class DealItemUpsertView(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']: + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'manager', 'prod_head']): return redirect('planning') + action = (request.POST.get('action') or '').strip() + if not action: + action = 'add' + + next_url = (request.POST.get('next') or '').strip() + next_url = next_url if next_url.startswith('/') else str(reverse_lazy('planning')) + def parse_int(s): s = (s or '').strip() return int(s) if s.isdigit() else None @@ -3273,24 +3372,111 @@ class DealItemUpsertView(LoginRequiredMixin, View): entity_id = parse_int(request.POST.get('entity_id')) qty = parse_int(request.POST.get('quantity')) - if not (deal_id and entity_id and qty and qty > 0): - messages.error(request, 'Заполни сущность и количество.') - next_url = (request.POST.get('next') or '').strip() - return redirect(next_url if next_url.startswith('/') else 'planning') + if not (deal_id and entity_id): + messages.error(request, 'Не выбрана сделка или сущность.') + return redirect(next_url) + + if action in ['add', 'set_qty']: + if not (qty and qty > 0): + messages.error(request, 'Заполни количество (больше 0).') + return redirect(next_url) try: - item, created = DealItem.objects.get_or_create(deal_id=deal_id, entity_id=entity_id, defaults={'quantity': qty}) - if not created: - item.quantity = qty - item.save() + with transaction.atomic(): + if action == 'delete': + item = DealItem.objects.select_for_update().filter(deal_id=deal_id, entity_id=entity_id).first() + if not item: + messages.error(request, 'Позиция сделки не найдена.') + return redirect(next_url) - _reconcile_default_delivery_batch(int(deal_id)) - messages.success(request, 'Позиция сделки сохранена.') + started = ( + DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id) + .aggregate(s=Coalesce(Sum('started_qty'), 0))['s'] + ) + started = int(started or 0) + + wi_agg = ( + WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id) + .aggregate(p=Coalesce(Sum('quantity_plan'), 0), d=Coalesce(Sum('quantity_done'), 0)) + ) + planned = int((wi_agg or {}).get('p') or 0) + done = int((wi_agg or {}).get('d') or 0) + + allocated = ( + DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id) + .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] + ) + allocated = int(allocated or 0) + + if started > 0 or planned > 0 or done > 0: + messages.error(request, 'Нельзя удалить позицию: по ней уже есть запуск/план/факт. Сначала откати производство.') + return redirect(next_url) + + if allocated > 0: + messages.error(request, 'Нельзя удалить позицию: она уже распределена по партиям поставки. Сначала удали строки партий.') + return redirect(next_url) + + DealEntityProgress.objects.filter(deal_id=deal_id, entity_id=entity_id).delete() + WorkItem.objects.filter(deal_id=deal_id, entity_id=entity_id).delete() + DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id).delete() + item.delete() + + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Позиция удалена из сделки.') + return redirect(next_url) + + item, created = DealItem.objects.select_for_update().get_or_create( + deal_id=deal_id, + entity_id=entity_id, + defaults={'quantity': int(qty)}, + ) + + if action == 'add': + if not created: + messages.warning(request, 'Позиция уже есть в сделке. Измени количество в строке позиции (OK).') + return redirect(next_url) + + item.quantity = int(qty) + item.save(update_fields=['quantity']) + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, 'Позиция сделки добавлена.') + return redirect(next_url) + + if action == 'set_qty': + started = ( + DealBatchItem.objects.filter(batch__deal_id=deal_id, entity_id=entity_id) + .aggregate(s=Coalesce(Sum('started_qty'), 0))['s'] + ) + started = int(started or 0) + + allocated_non_default = ( + DealBatchItem.objects.filter(batch__deal_id=deal_id, batch__is_default=False, entity_id=entity_id) + .aggregate(s=Coalesce(Sum('quantity'), 0))['s'] + ) + allocated_non_default = int(allocated_non_default or 0) + + if int(qty) < started: + messages.error(request, f'Нельзя поставить {qty} шт: уже запущено {started} шт в производство.') + return redirect(next_url) + + if int(qty) < allocated_non_default: + messages.error(request, f'Нельзя поставить {qty} шт: по партиям уже распределено {allocated_non_default} шт.') + return redirect(next_url) + + before = int(item.quantity or 0) + if before != int(qty): + item.quantity = int(qty) + item.save(update_fields=['quantity']) + + _reconcile_default_delivery_batch(int(deal_id)) + messages.success(request, f'Количество по сделке обновлено: {before} → {item.quantity}.') + return redirect(next_url) + + messages.error(request, 'Неизвестное действие.') + return redirect(next_url) except Exception as e: messages.error(request, f'Ошибка: {e}') - - next_url = (request.POST.get('next') or '').strip() - return redirect(next_url if next_url.startswith('/') else 'planning') + return redirect(next_url) class DirectoriesView(LoginRequiredMixin, TemplateView): @@ -4180,6 +4366,128 @@ class WarehouseReceiptCreateView(LoginRequiredMixin, View): return redirect(next_url) +class ShippingView(LoginRequiredMixin, TemplateView): + template_name = 'shiftflow/shipping.html' + + def dispatch(self, request, *args, **kwargs): + profile = getattr(request.user, 'profile', None) + roles = get_user_roles(request.user) + self.role = primary_role(roles) + self.roles = roles + self.is_readonly = bool(getattr(profile, 'is_readonly', False)) if profile else False + self.can_edit = has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'technologist']) and not self.is_readonly + + if not has_any_role(roles, ['admin', 'clerk', 'manager', 'prod_head', 'director', 'technologist', 'observer']): + return redirect('registry') + + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['user_role'] = self.role + ctx['user_roles'] = sorted(self.roles) + ctx['can_edit'] = bool(self.can_edit) + ctx['is_readonly'] = bool(self.is_readonly) + + deal_id_raw = (self.request.GET.get('deal_id') or '').strip() + deal_id = int(deal_id_raw) if deal_id_raw.isdigit() else None + + shipping_loc, _ = Location.objects.get_or_create( + name='Склад отгруженных позиций', + defaults={'is_production_area': False}, + ) + ctx['shipping_location'] = shipping_loc + + ctx['deals'] = list(Deal.objects.select_related('company').order_by('-id')[:300]) + + ctx['selected_deal_id'] = deal_id + + ctx['entity_rows'] = [] + ctx['material_rows'] = [] + + if deal_id: + from shiftflow.services.shipping import build_shipment_rows + + entity_rows, material_rows = build_shipment_rows( + deal_id=int(deal_id), + shipping_location_id=int(shipping_loc.id), + ) + ctx['entity_rows'] = entity_rows + ctx['material_rows'] = material_rows + + return ctx + + def post(self, request, *args, **kwargs): + if not self.can_edit: + messages.error(request, 'Доступ только для просмотра.') + return redirect('shipping') + + deal_id_raw = (request.POST.get('deal_id') or '').strip() + + if not deal_id_raw.isdigit(): + messages.error(request, 'Выбери сделку.') + return redirect('shipping') + + deal_id = int(deal_id_raw) + + shipping_loc, _ = Location.objects.get_or_create( + name='Склад отгруженных позиций', + defaults={'is_production_area': False}, + ) + + entity_qty: dict[int, int] = {} + material_qty: dict[int, float] = {} + + for k, v in request.POST.items(): + if not k or v is None: + continue + s = (str(v) or '').strip().replace(',', '.') + + if k.startswith('ent_'): + ent_id_raw = k.replace('ent_', '').strip() + if not ent_id_raw.isdigit(): + continue + try: + qty = int(float(s)) if s else 0 + except ValueError: + qty = 0 + if qty > 0: + entity_qty[int(ent_id_raw)] = int(qty) + + if k.startswith('mat_'): + mat_id_raw = k.replace('mat_', '').strip() + if not mat_id_raw.isdigit(): + continue + try: + qty_f = float(s) if s else 0.0 + except ValueError: + qty_f = 0.0 + if qty_f > 0: + material_qty[int(mat_id_raw)] = float(qty_f) + + if not entity_qty and not material_qty: + messages.error(request, 'Укажи количество к отгрузке хотя бы по одной позиции.') + return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}&from_location_id={from_location_id}") + + from shiftflow.services.shipping import create_shipment_transfers + + try: + ids = create_shipment_transfers( + deal_id=int(deal_id), + shipping_location_id=int(shipping_loc.id), + entity_qty=entity_qty, + material_qty=material_qty, + user_id=int(request.user.id), + ) + msg = ', '.join([str(i) for i in ids]) + messages.success(request, f'Отгрузка оформлена. Документы перемещения: {msg}.') + except Exception as e: + logger.exception('shipping:error deal_id=%s', deal_id) + messages.error(request, f'Ошибка отгрузки: {e}') + + return redirect(f"{reverse_lazy('shipping')}?deal_id={deal_id}") + + from shiftflow.services.assembly_closing import get_assembly_closing_info, apply_assembly_closing class AssemblyClosingView(LoginRequiredMixin, TemplateView): @@ -5137,9 +5445,8 @@ class ProductsView(LoginRequiredMixin, TemplateView): 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', 'technologist']: + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist']): return redirect('products') entity_type = (request.POST.get('entity_type') or '').strip() @@ -5176,10 +5483,12 @@ class ProductDetailView(LoginRequiredMixin, TemplateView): 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') + roles = get_user_roles(self.request.user) + role = primary_role(roles) ctx['user_role'] = role - ctx['can_edit'] = role in ['admin', 'technologist'] - ctx['can_add_to_deal'] = role in ['admin', 'technologist'] + ctx['user_roles'] = sorted(roles) + ctx['can_edit'] = has_any_role(roles, ['admin', 'technologist']) + ctx['can_add_to_deal'] = has_any_role(roles, ['admin', 'technologist']) entity = get_object_or_404(ProductEntity.objects.select_related('planned_material'), pk=int(self.kwargs['pk'])) ctx['entity'] = entity @@ -5377,9 +5686,8 @@ class ProductDetailView(LoginRequiredMixin, TemplateView): class ProductInfoView(LoginRequiredMixin, TemplateView): 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', 'observer']: + roles = get_user_roles(request.user) + if not has_any_role(roles, ['admin', 'technologist', 'observer']): return redirect('registry') return super().dispatch(request, *args, **kwargs) @@ -5415,6 +5723,7 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): .filter(entity_id=entity.id) .order_by('seq', 'id') ) + ctx['selected_operation_ids'] = [int(x.operation_id) for x in ctx['entity_ops'] if getattr(x, 'operation_id', None)] ctx['operations'] = list(Operation.objects.select_related('workshop').order_by('name')) next_url = (self.request.GET.get('next') or '').strip() @@ -5794,6 +6103,28 @@ class ProductInfoView(LoginRequiredMixin, TemplateView): passport.notes = (request.POST.get('notes') or '').strip() passport.save() + if 'operation_ids' in request.POST: + op_ids = [int(x) for x in request.POST.getlist('operation_ids') if str(x).isdigit()] + op_ids = list(dict.fromkeys(op_ids)) + valid = set(Operation.objects.filter(id__in=op_ids).values_list('id', flat=True)) + op_ids = [int(x) for x in op_ids if int(x) in valid] + + EntityOperation.objects.filter(entity_id=entity.id).exclude(operation_id__in=op_ids).delete() + + existing = list(EntityOperation.objects.filter(entity_id=entity.id, operation_id__in=op_ids).order_by('id')) + by_op = {int(eo.operation_id): eo for eo in existing} + + if existing: + EntityOperation.objects.filter(id__in=[eo.id for eo in existing]).update(seq=0) + + for i, op_id in enumerate(op_ids, start=1): + eo = by_op.get(int(op_id)) + if eo: + if int(eo.seq or 0) != i: + EntityOperation.objects.filter(id=eo.id).update(seq=i) + else: + EntityOperation.objects.create(entity_id=entity.id, operation_id=int(op_id), seq=i) + messages.success(request, 'Сохранено.') return redirect(stay_url) diff --git a/templates/components/_navbar.html b/templates/components/_navbar.html index fb33827..6a1071a 100644 --- a/templates/components/_navbar.html +++ b/templates/components/_navbar.html @@ -26,6 +26,12 @@ Сделки + {% if user_role in 'admin,clerk,manager,prod_head,director,observer,technologist' %} + + {% endif %} + {% if user_role in 'admin,technologist,master,clerk' %}