Добавил страницу отгрузки, подправил логику генерации сменных заданий. Организовал редактирование позици сделок
All checks were successful
Deploy MES Core / deploy (push) Successful in 29s
All checks were successful
Deploy MES Core / deploy (push) Successful in 29s
This commit is contained in:
@@ -436,4 +436,78 @@ def explode_roots_additive(
|
||||
skipped_no_material,
|
||||
skipped_supply,
|
||||
)
|
||||
return ExplosionStats(tasks_created, tasks_updated, 0, 0)
|
||||
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)
|
||||
285
shiftflow/services/shipping.py
Normal file
285
shiftflow/services/shipping.py
Normal file
@@ -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
|
||||
@@ -6,6 +6,7 @@
|
||||
<th data-sort-type="date">Дата</th>
|
||||
<th>Сделка</th>
|
||||
<th>Цех/Пост</th>
|
||||
<th>Операция</th>
|
||||
<th>Наименование</th>
|
||||
<th>Материал</th>
|
||||
<th data-sort="false" class="text-center">Файлы</th>
|
||||
@@ -28,6 +29,9 @@
|
||||
<span class="badge bg-secondary">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small">
|
||||
{{ wi.operation.name|default:wi.stage|default:"—" }}
|
||||
</td>
|
||||
<td class="fw-bold">
|
||||
{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}
|
||||
</td>
|
||||
|
||||
@@ -40,7 +40,13 @@
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user_role in 'admin,technologist' %}
|
||||
{% if user_role in 'admin,clerk,manager,prod_head,technologist' %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'shipping' %}?deal_id={{ deal.id }}">
|
||||
<i class="bi bi-truck me-1"></i>Отгрузка
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if user_role in 'admin,technologist,manager,prod_head' %}
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" data-bs-toggle="modal" data-bs-target="#dealItemModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>Добавить задание
|
||||
</button>
|
||||
@@ -65,7 +71,7 @@
|
||||
<th data-sort="false" style="width: 160px;">Прогресс</th>
|
||||
<th class="text-center">Заказано / Сделано / В плане</th>
|
||||
<th class="text-center">Осталось</th>
|
||||
<th data-sort="false" class="text-end">В производство</th>
|
||||
<th data-sort="false" class="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -88,20 +94,46 @@
|
||||
</td>
|
||||
<td class="text-center">{{ it.remaining_qty }}</td>
|
||||
<td class="text-end" onclick="event.stopPropagation();">
|
||||
{% if user_role in 'admin,technologist' %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-accent btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#startProductionModal"
|
||||
data-entity-id="{{ it.entity.id }}"
|
||||
data-entity-label="{{ it.entity.drawing_number|default:'—' }} {{ it.entity.name }}"
|
||||
>
|
||||
<i class="bi bi-play-fill me-1"></i>В производство
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" disabled>В производство</button>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-end gap-1 flex-wrap" onclick="event.stopPropagation();">
|
||||
{% if user_role in 'admin,technologist,manager,prod_head' %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-accent btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#startProductionModal"
|
||||
data-entity-id="{{ it.entity.id }}"
|
||||
data-entity-label="{{ it.entity.drawing_number|default:'—' }} {{ it.entity.name }}"
|
||||
>
|
||||
<i class="bi bi-play-fill me-1"></i>В производство
|
||||
</button>
|
||||
|
||||
<form method="post" action="{% url 'deal_item_upsert' %}" class="d-inline-flex gap-1 align-items-center" onclick="event.stopPropagation();">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="set_qty">
|
||||
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||
<input type="hidden" name="entity_id" value="{{ it.entity.id }}">
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<input class="form-control form-control-sm bg-body text-body border-secondary" style="width:90px;" type="number" min="1" name="quantity" value="{{ it.quantity }}" title="Кол-во по сделке" required>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit" title="Обновить количество">OK</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#dealItemDeleteModal"
|
||||
data-deal-id="{{ deal.id }}"
|
||||
data-entity-id="{{ it.entity.id }}"
|
||||
data-next="{{ request.get_full_path }}"
|
||||
data-entity-label="{{ it.entity.drawing_number|default:'—' }} {{ it.entity.name }}"
|
||||
title="Удалить из сделки"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" disabled>В производство</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
@@ -182,6 +214,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if user_role in 'admin,technologist' %}
|
||||
{% if bi.started_qty and bi.started_qty > 0 %}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-warning btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#rollbackProductionModal{{ bi.id }}"
|
||||
title="Откатить запуск в производство"
|
||||
>
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if user_role in 'admin,technologist' and not b.is_default %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#dealBatchItemModal" data-batch-id="{{ b.id }}">Добавить</button>
|
||||
<form method="post" action="{% url 'deal_batch_action' %}" class="d-inline">
|
||||
@@ -202,6 +248,39 @@
|
||||
{% else %}
|
||||
<div class="text-muted">Пусто</div>
|
||||
{% endif %}
|
||||
|
||||
{% for bi in b.items_list %}
|
||||
{% if user_role in 'admin,technologist' and bi.started_qty and bi.started_qty > 0 %}
|
||||
<div class="modal fade" id="rollbackProductionModal{{ bi.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{% url 'deal_batch_action' %}" class="modal-content border-secondary">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="rollback_batch_item_production">
|
||||
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||
<input type="hidden" name="item_id" value="{{ bi.id }}">
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Откатить запуск</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="small text-muted mb-2">{{ bi.entity.drawing_number|default:"—" }} {{ bi.entity.name }}</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Сколько откатить, шт</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="number" min="1" max="{{ bi.started_qty }}" name="quantity" value="{{ bi.started_qty }}" required>
|
||||
<div class="form-text">Запущено в партии: {{ bi.started_qty }} шт</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-outline-warning">Откатить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% if user_role in 'admin,technologist' and not b.is_default %}
|
||||
@@ -526,14 +605,19 @@
|
||||
|
||||
<div class="modal fade" id="dealItemModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<form method="post" action="{% url 'deal_item_upsert' %}" class="modal-content border-secondary">
|
||||
<form method="post" action="{% url 'deal_item_upsert' %}" class="modal-content border-secondary" id="dealItemForm">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="hidden" name="deal_id" value="{{ deal.id }}">
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
<input type="hidden" name="entity_id" id="diEntityId" required>
|
||||
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Добавить позицию сделки</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
@@ -546,37 +630,60 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" id="diDn" placeholder="Опционально">
|
||||
<input class="form-control bg-body text-body border-secondary" id="diDn" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" id="diName" placeholder="Напр. Основание">
|
||||
<input class="form-control bg-body text-body border-secondary" id="diName" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-2 d-grid">
|
||||
<button type="button" class="btn btn-outline-secondary" id="diSearchBtn">Поиск</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Найдено</label>
|
||||
<select class="form-select bg-body text-body border-secondary" id="diFound"></select>
|
||||
<input type="hidden" name="entity_id" id="diEntityId" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Кол-во, шт</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="quantity" id="diQty" value="1" required>
|
||||
</div>
|
||||
<div class="small text-muted mt-2" id="diSearchStatus"></div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Результаты</label>
|
||||
<select class="form-select bg-body text-body border-secondary" id="diFound" size="8"></select>
|
||||
<div class="form-text">Выбери строку и нажми «Добавить».</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-outline-accent">Сохранить</button>
|
||||
<button type="submit" class="btn btn-outline-accent">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="dealItemDeleteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{% url 'deal_item_upsert' %}" class="modal-content border-secondary">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="deal_id" id="diDelDealId" value="">
|
||||
<input type="hidden" name="entity_id" id="diDelEntityId" value="">
|
||||
<input type="hidden" name="next" id="diDelNext" value="">
|
||||
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Удалить позицию</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">Вы уверены?</div>
|
||||
<div class="small text-muted" id="diDelLabel"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-outline-danger">Удалить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('tr.deal-entity-row[data-href]').forEach(tr => {
|
||||
@@ -593,6 +700,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const spQty = document.getElementById('spQty');
|
||||
const spSubmit = document.getElementById('spSubmit');
|
||||
|
||||
const delModal = document.getElementById('dealItemDeleteModal');
|
||||
const delDealId = document.getElementById('diDelDealId');
|
||||
const delEntityId = document.getElementById('diDelEntityId');
|
||||
const delNext = document.getElementById('diDelNext');
|
||||
const delLabel = document.getElementById('diDelLabel');
|
||||
|
||||
if (delModal) {
|
||||
delModal.addEventListener('shown.bs.modal', (event) => {
|
||||
const btn = event.relatedTarget;
|
||||
const dealId = btn ? (btn.getAttribute('data-deal-id') || '') : '';
|
||||
const entityId = btn ? (btn.getAttribute('data-entity-id') || '') : '';
|
||||
const nextUrl = btn ? (btn.getAttribute('data-next') || '') : '';
|
||||
const label = btn ? (btn.getAttribute('data-entity-label') || '') : '';
|
||||
|
||||
if (delDealId) delDealId.value = dealId;
|
||||
if (delEntityId) delEntityId.value = entityId;
|
||||
if (delNext) delNext.value = nextUrl;
|
||||
if (delLabel) delLabel.textContent = label;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function spApplyFilter(entityId) {
|
||||
if (!spSelect) return;
|
||||
let firstVisible = null;
|
||||
@@ -642,6 +771,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (spQty) spQty.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -685,7 +815,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modalEl = document.getElementById('dealItemModal');
|
||||
const formEl = modalEl ? modalEl.querySelector('form') : null;
|
||||
const formEl = document.getElementById('dealItemForm');
|
||||
|
||||
const typeEl = document.getElementById('diType');
|
||||
const dnEl = document.getElementById('diDn');
|
||||
@@ -693,24 +823,56 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const foundEl = document.getElementById('diFound');
|
||||
const idEl = document.getElementById('diEntityId');
|
||||
const btn = document.getElementById('diSearchBtn');
|
||||
const statusEl = document.getElementById('diSearchStatus');
|
||||
|
||||
if (!typeEl || !dnEl || !nameEl || !foundEl || !idEl) return;
|
||||
if (!modalEl || !formEl || !typeEl || !dnEl || !nameEl || !foundEl || !idEl || !btn || !statusEl) return;
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text || '';
|
||||
}
|
||||
|
||||
function setSelectedFromFound() {
|
||||
idEl.value = foundEl.value || '';
|
||||
}
|
||||
|
||||
async function search(opts = { focusFound: false }) {
|
||||
async function runSearch() {
|
||||
const params = new URLSearchParams({
|
||||
entity_type: (typeEl.value || ''),
|
||||
q_dn: (dnEl.value || ''),
|
||||
q_name: (nameEl.value || ''),
|
||||
});
|
||||
const res = await fetch('{% url "entities_search" %}?' + params.toString(), { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const items = (data && data.results) || [];
|
||||
|
||||
setStatus('Поиск...');
|
||||
foundEl.innerHTML = '';
|
||||
idEl.value = '';
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('{% url "entities_search" %}?' + params.toString(), { credentials: 'same-origin' });
|
||||
} catch (_) {
|
||||
setStatus('Ошибка сети при поиске.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus(`Ошибка поиска: ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (_) {
|
||||
setStatus('Ошибка: сервер вернул не JSON.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.error) {
|
||||
setStatus(`Ошибка поиска: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = (data && data.results) || [];
|
||||
items.forEach(it => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(it.id);
|
||||
@@ -718,50 +880,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
foundEl.appendChild(opt);
|
||||
});
|
||||
|
||||
const count = (data && typeof data.count === 'number') ? data.count : items.length;
|
||||
|
||||
if (items.length) {
|
||||
foundEl.value = String(items[0].id);
|
||||
setSelectedFromFound();
|
||||
if (opts && opts.focusFound) {
|
||||
foundEl.focus({ preventScroll: true });
|
||||
}
|
||||
setStatus(`Найдено: ${count}`);
|
||||
foundEl.focus({ preventScroll: true });
|
||||
} else {
|
||||
idEl.value = '';
|
||||
setStatus(`Ничего не найдено (0).`);
|
||||
}
|
||||
|
||||
foundEl.onchange = setSelectedFromFound;
|
||||
return items;
|
||||
}
|
||||
|
||||
if (btn) btn.onclick = () => { search({ focusFound: true }); };
|
||||
btn.addEventListener('click', () => runSearch());
|
||||
foundEl.addEventListener('change', () => setSelectedFromFound());
|
||||
|
||||
const onEnterSearch = (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
search({ focusFound: true });
|
||||
runSearch();
|
||||
};
|
||||
|
||||
dnEl.addEventListener('keydown', onEnterSearch);
|
||||
nameEl.addEventListener('keydown', onEnterSearch);
|
||||
|
||||
foundEl.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
formEl.addEventListener('submit', (e) => {
|
||||
setSelectedFromFound();
|
||||
if (idEl.value && formEl) {
|
||||
formEl.requestSubmit();
|
||||
} else {
|
||||
search({ focusFound: true });
|
||||
if (!idEl.value) {
|
||||
e.preventDefault();
|
||||
setStatus('Выбери позицию из результатов поиска.');
|
||||
}
|
||||
});
|
||||
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('shown.bs.modal', () => {
|
||||
setTimeout(() => {
|
||||
dnEl.focus({ preventScroll: true });
|
||||
dnEl.select();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
modalEl.addEventListener('shown.bs.modal', () => {
|
||||
setStatus('');
|
||||
dnEl.focus({ preventScroll: true });
|
||||
dnEl.select();
|
||||
});
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('tr.task-row[data-href]').forEach(function (row) {
|
||||
@@ -889,5 +1043,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -25,72 +25,33 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="container-fluid p-0">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Тип</label>
|
||||
<input class="form-control bg-body text-body border-secondary" value="{{ entity.get_entity_type_display }}" disabled>
|
||||
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Заполнен</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="pf">Заполнено</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Чертёж (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not can_edit %}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Техпроцесс</label>
|
||||
<div class="form-control bg-body text-body border-secondary">
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Сварка</label>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Заполнен</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="requires_welding" id="rw" {% if passport and passport.requires_welding %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="rw">Требуется сварка</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Покраска</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="requires_painting" id="rp" {% if passport and passport.requires_painting %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="rp">Требуется покраска</label>
|
||||
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="pf">Заполнено</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +75,45 @@
|
||||
<input class="form-control bg-body text-body border-secondary" name="coating_area_m2" value="{% if passport and passport.coating_area_m2 %}{{ passport.coating_area_m2 }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.preview %}
|
||||
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not can_edit %}
|
||||
<div class="col-12">
|
||||
<label class="form-label">Техпроцесс</label>
|
||||
<div class="form-control bg-body text-body border-secondary">
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Технические требования</label>
|
||||
<textarea class="form-control bg-body text-body border-secondary" name="technical_requirements" rows="4" {% if not can_edit %}disabled{% endif %}>{% if passport %}{{ passport.technical_requirements }}{% endif %}</textarea>
|
||||
@@ -128,7 +128,7 @@
|
||||
</form>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="mt-3">
|
||||
<div class="mt-3" id="techprocess-editor" data-form-id="product-info-form">
|
||||
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -137,69 +137,154 @@
|
||||
<tr class="table-custom-header">
|
||||
<th style="width:70px;">№</th>
|
||||
<th>Операция</th>
|
||||
<th>Цех</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
<th style="width:220px;" class="text-end" data-sort="false">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eo in entity_ops %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ eo.seq }}</td>
|
||||
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_entity_operation">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||
{% endfor %}
|
||||
<tbody id="tpRows">
|
||||
{% if selected_operation_ids %}
|
||||
{% for op_id in selected_operation_ids %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}" {% if op.id == op_id %}selected{% endif %}>{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for i in '1234'|make_list %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_entity_operation">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="tpAddRow">+ строка</button>
|
||||
<button class="btn btn-outline-accent" type="submit" form="product-info-form">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-10">
|
||||
<label class="form-label">Добавить операцию</label>
|
||||
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="tpHidden"></div>
|
||||
|
||||
<template id="tpRowTemplate">
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx"></td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const root = document.getElementById('techprocess-editor');
|
||||
if (!root) return;
|
||||
|
||||
const formId = root.getAttribute('data-form-id') || 'product-info-form';
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
const tbody = document.getElementById('tpRows');
|
||||
const addBtn = document.getElementById('tpAddRow');
|
||||
const hidden = document.getElementById('tpHidden');
|
||||
const tpl = document.getElementById('tpRowTemplate');
|
||||
|
||||
function renumber() {
|
||||
tbody.querySelectorAll('.tp-row').forEach((tr, idx) => {
|
||||
const cell = tr.querySelector('.tp-idx');
|
||||
if (cell) cell.textContent = String(idx + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRow(tr) {
|
||||
tr.querySelector('.tp-up')?.addEventListener('click', () => {
|
||||
const prev = tr.previousElementSibling;
|
||||
if (prev) tbody.insertBefore(tr, prev);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-down')?.addEventListener('click', () => {
|
||||
const next = tr.nextElementSibling;
|
||||
if (next) tbody.insertBefore(next, tr);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-del')?.addEventListener('click', () => {
|
||||
tr.remove();
|
||||
renumber();
|
||||
});
|
||||
}
|
||||
|
||||
tbody.querySelectorAll('.tp-row').forEach(bindRow);
|
||||
renumber();
|
||||
|
||||
addBtn?.addEventListener('click', () => {
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const tr = frag.querySelector('tr');
|
||||
tbody.appendChild(frag);
|
||||
if (tr) bindRow(tr);
|
||||
renumber();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
hidden.innerHTML = '';
|
||||
const values = [];
|
||||
tbody.querySelectorAll('.tp-select').forEach(sel => {
|
||||
const v = (sel.value || '').toString().trim();
|
||||
if (!v) return;
|
||||
if (values.includes(v)) return;
|
||||
values.push(v);
|
||||
});
|
||||
values.forEach(v => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'operation_ids';
|
||||
inp.value = v;
|
||||
inp.setAttribute('form', formId);
|
||||
hidden.appendChild(inp);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -290,6 +375,7 @@
|
||||
<th>Тип</th>
|
||||
<th>Обозначение</th>
|
||||
<th>Наименование</th>
|
||||
<th class="text-center" style="width:120px;">Заполнено</th>
|
||||
<th class="text-center">Кол-во</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
</tr>
|
||||
@@ -300,6 +386,13 @@
|
||||
<td class="small text-muted">{{ ln.child.get_entity_type_display }}</td>
|
||||
<td class="fw-bold">{{ ln.child.drawing_number|default:"—" }}</td>
|
||||
<td>{{ ln.child.name }}</td>
|
||||
<td class="text-center">
|
||||
{% if ln.child.passport_filled %}
|
||||
<span class="badge bg-success">Да</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center" style="max-width:220px;" onclick="event.stopPropagation();">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="d-flex gap-2 align-items-center justify-content-center" onclick="event.stopPropagation();">
|
||||
{% csrf_token %}
|
||||
@@ -325,7 +418,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Пока нет компонентов</td></tr>
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">Пока нет компонентов</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -25,24 +25,29 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="container-fluid p-0">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Тип</label>
|
||||
<input class="form-control bg-body text-body border-secondary" value="{{ entity.get_entity_type_display }}" disabled>
|
||||
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Заполнен</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -50,11 +55,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Материал литья</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="casting_material" value="{% if passport %}{{ passport.casting_material }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -80,7 +80,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.pdf_main %}
|
||||
@@ -88,7 +88,15 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.preview %}
|
||||
@@ -105,7 +113,7 @@
|
||||
</form>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="mt-3">
|
||||
<div class="mt-3" id="techprocess-editor" data-form-id="product-info-form">
|
||||
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -114,69 +122,154 @@
|
||||
<tr class="table-custom-header">
|
||||
<th style="width:70px;">№</th>
|
||||
<th>Операция</th>
|
||||
<th>Цех</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
<th style="width:220px;" class="text-end" data-sort="false">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eo in entity_ops %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ eo.seq }}</td>
|
||||
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_entity_operation">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||
{% endfor %}
|
||||
<tbody id="tpRows">
|
||||
{% if selected_operation_ids %}
|
||||
{% for op_id in selected_operation_ids %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}" {% if op.id == op_id %}selected{% endif %}>{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for i in '1234'|make_list %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_entity_operation">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="tpAddRow">+ строка</button>
|
||||
<button class="btn btn-outline-accent" type="submit" form="product-info-form">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-10">
|
||||
<label class="form-label">Добавить операцию</label>
|
||||
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="tpHidden"></div>
|
||||
|
||||
<template id="tpRowTemplate">
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx"></td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const root = document.getElementById('techprocess-editor');
|
||||
if (!root) return;
|
||||
|
||||
const formId = root.getAttribute('data-form-id') || 'product-info-form';
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
const tbody = document.getElementById('tpRows');
|
||||
const addBtn = document.getElementById('tpAddRow');
|
||||
const hidden = document.getElementById('tpHidden');
|
||||
const tpl = document.getElementById('tpRowTemplate');
|
||||
|
||||
function renumber() {
|
||||
tbody.querySelectorAll('.tp-row').forEach((tr, idx) => {
|
||||
const cell = tr.querySelector('.tp-idx');
|
||||
if (cell) cell.textContent = String(idx + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRow(tr) {
|
||||
tr.querySelector('.tp-up')?.addEventListener('click', () => {
|
||||
const prev = tr.previousElementSibling;
|
||||
if (prev) tbody.insertBefore(tr, prev);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-down')?.addEventListener('click', () => {
|
||||
const next = tr.nextElementSibling;
|
||||
if (next) tbody.insertBefore(next, tr);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-del')?.addEventListener('click', () => {
|
||||
tr.remove();
|
||||
renumber();
|
||||
});
|
||||
}
|
||||
|
||||
tbody.querySelectorAll('.tp-row').forEach(bindRow);
|
||||
renumber();
|
||||
|
||||
addBtn?.addEventListener('click', () => {
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const tr = frag.querySelector('tr');
|
||||
tbody.appendChild(frag);
|
||||
if (tr) bindRow(tr);
|
||||
renumber();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
hidden.innerHTML = '';
|
||||
const values = [];
|
||||
tbody.querySelectorAll('.tp-select').forEach(sel => {
|
||||
const v = (sel.value || '').toString().trim();
|
||||
if (!v) return;
|
||||
if (values.includes(v)) return;
|
||||
values.push(v);
|
||||
});
|
||||
values.forEach(v => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'operation_ids';
|
||||
inp.value = v;
|
||||
inp.setAttribute('form', formId);
|
||||
hidden.appendChild(inp);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -25,24 +25,29 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="container-fluid p-0">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Тип</label>
|
||||
<input class="form-control bg-body text-body border-secondary" value="{{ entity.get_entity_type_display }}" disabled>
|
||||
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Заполнен</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -50,13 +55,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж/ТЗ (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.preview %}
|
||||
<div class="small mt-1"><a href="{{ entity.preview.url }}" target="_blank">Открыть текущую</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not can_edit %}
|
||||
<div class="col-md-6">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Техпроцесс</label>
|
||||
<div class="form-control bg-body text-body border-secondary">
|
||||
{% if entity_ops %}
|
||||
@@ -70,12 +94,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Чертёж/ТЗ (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.pdf_main %}
|
||||
<div class="small mt-1"><a href="{{ entity.pdf_main.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
<div class="col-12">
|
||||
<label class="form-label">Пояснения</label>
|
||||
<textarea class="form-control bg-body text-body border-secondary" name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{% if passport %}{{ passport.notes }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
@@ -83,11 +104,6 @@
|
||||
<textarea class="form-control bg-body text-body border-secondary" name="technical_requirements" rows="4" {% if not can_edit %}disabled{% endif %}>{% if passport %}{{ passport.technical_requirements }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Пояснения</label>
|
||||
<textarea class="form-control bg-body text-body border-secondary" name="notes" rows="3" {% if not can_edit %}disabled{% endif %}>{% if passport %}{{ passport.notes }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex justify-content-end mt-2">
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-outline-accent" type="submit">Сохранить</button>
|
||||
@@ -97,7 +113,7 @@
|
||||
</form>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="mt-3">
|
||||
<div class="mt-3" id="techprocess-editor" data-form-id="product-info-form">
|
||||
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -106,69 +122,154 @@
|
||||
<tr class="table-custom-header">
|
||||
<th style="width:70px;">№</th>
|
||||
<th>Операция</th>
|
||||
<th>Цех</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
<th style="width:220px;" class="text-end" data-sort="false">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eo in entity_ops %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ eo.seq }}</td>
|
||||
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_entity_operation">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||
{% endfor %}
|
||||
<tbody id="tpRows">
|
||||
{% if selected_operation_ids %}
|
||||
{% for op_id in selected_operation_ids %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}" {% if op.id == op_id %}selected{% endif %}>{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for i in '1234'|make_list %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_entity_operation">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="tpAddRow">+ строка</button>
|
||||
<button class="btn btn-outline-accent" type="submit" form="product-info-form">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-10">
|
||||
<label class="form-label">Добавить операцию</label>
|
||||
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="tpHidden"></div>
|
||||
|
||||
<template id="tpRowTemplate">
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx"></td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const root = document.getElementById('techprocess-editor');
|
||||
if (!root) return;
|
||||
|
||||
const formId = root.getAttribute('data-form-id') || 'product-info-form';
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
const tbody = document.getElementById('tpRows');
|
||||
const addBtn = document.getElementById('tpAddRow');
|
||||
const hidden = document.getElementById('tpHidden');
|
||||
const tpl = document.getElementById('tpRowTemplate');
|
||||
|
||||
function renumber() {
|
||||
tbody.querySelectorAll('.tp-row').forEach((tr, idx) => {
|
||||
const cell = tr.querySelector('.tp-idx');
|
||||
if (cell) cell.textContent = String(idx + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRow(tr) {
|
||||
tr.querySelector('.tp-up')?.addEventListener('click', () => {
|
||||
const prev = tr.previousElementSibling;
|
||||
if (prev) tbody.insertBefore(tr, prev);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-down')?.addEventListener('click', () => {
|
||||
const next = tr.nextElementSibling;
|
||||
if (next) tbody.insertBefore(next, tr);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-del')?.addEventListener('click', () => {
|
||||
tr.remove();
|
||||
renumber();
|
||||
});
|
||||
}
|
||||
|
||||
tbody.querySelectorAll('.tp-row').forEach(bindRow);
|
||||
renumber();
|
||||
|
||||
addBtn?.addEventListener('click', () => {
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const tr = frag.querySelector('tr');
|
||||
tbody.appendChild(frag);
|
||||
if (tr) bindRow(tr);
|
||||
renumber();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
hidden.innerHTML = '';
|
||||
const values = [];
|
||||
tbody.querySelectorAll('.tp-select').forEach(sel => {
|
||||
const v = (sel.value || '').toString().trim();
|
||||
if (!v) return;
|
||||
if (values.includes(v)) return;
|
||||
values.push(v);
|
||||
});
|
||||
values.forEach(v => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'operation_ids';
|
||||
inp.value = v;
|
||||
inp.setAttribute('form', formId);
|
||||
hidden.appendChild(inp);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -25,24 +25,29 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="container-fluid p-0">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Тип</label>
|
||||
<input class="form-control bg-body text-body border-secondary" value="{{ entity.get_entity_type_display }}" disabled>
|
||||
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Заполнен</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -50,11 +55,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Материал заготовки</label>
|
||||
<select class="form-select bg-body text-body border-secondary" name="planned_material_id" {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -65,21 +65,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if not can_edit %}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Техпроцесс</label>
|
||||
<div class="form-control bg-body text-body border-secondary">
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Толщина, мм</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="thickness_mm" value="{% if passport and passport.thickness_mm %}{{ passport.thickness_mm }}{% endif %}" inputmode="decimal" {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -105,7 +90,22 @@
|
||||
<input class="form-control bg-body text-body border-secondary" name="pierce_count" value="{% if passport and passport.pierce_count %}{{ passport.pierce_count }}{% endif %}" inputmode="numeric" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
{% if not can_edit %}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Техпроцесс</label>
|
||||
<div class="form-control bg-body text-body border-secondary">
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.pdf_main %}
|
||||
@@ -113,15 +113,15 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" {% if not can_edit %}disabled{% endif %}>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.preview %}
|
||||
@@ -148,7 +148,7 @@
|
||||
</form>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="mt-3">
|
||||
<div class="mt-3" id="techprocess-editor" data-form-id="product-info-form">
|
||||
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -157,69 +157,154 @@
|
||||
<tr class="table-custom-header">
|
||||
<th style="width:70px;">№</th>
|
||||
<th>Операция</th>
|
||||
<th>Цех</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
<th style="width:220px;" class="text-end" data-sort="false">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eo in entity_ops %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ eo.seq }}</td>
|
||||
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_entity_operation">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||
{% endfor %}
|
||||
<tbody id="tpRows">
|
||||
{% if selected_operation_ids %}
|
||||
{% for op_id in selected_operation_ids %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}" {% if op.id == op_id %}selected{% endif %}>{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for i in '1234'|make_list %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_entity_operation">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="tpAddRow">+ строка</button>
|
||||
<button class="btn btn-outline-accent" type="submit" form="product-info-form">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-10">
|
||||
<label class="form-label">Добавить операцию</label>
|
||||
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="tpHidden"></div>
|
||||
|
||||
<template id="tpRowTemplate">
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx"></td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const root = document.getElementById('techprocess-editor');
|
||||
if (!root) return;
|
||||
|
||||
const formId = root.getAttribute('data-form-id') || 'product-info-form';
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
const tbody = document.getElementById('tpRows');
|
||||
const addBtn = document.getElementById('tpAddRow');
|
||||
const hidden = document.getElementById('tpHidden');
|
||||
const tpl = document.getElementById('tpRowTemplate');
|
||||
|
||||
function renumber() {
|
||||
tbody.querySelectorAll('.tp-row').forEach((tr, idx) => {
|
||||
const cell = tr.querySelector('.tp-idx');
|
||||
if (cell) cell.textContent = String(idx + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRow(tr) {
|
||||
tr.querySelector('.tp-up')?.addEventListener('click', () => {
|
||||
const prev = tr.previousElementSibling;
|
||||
if (prev) tbody.insertBefore(tr, prev);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-down')?.addEventListener('click', () => {
|
||||
const next = tr.nextElementSibling;
|
||||
if (next) tbody.insertBefore(next, tr);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-del')?.addEventListener('click', () => {
|
||||
tr.remove();
|
||||
renumber();
|
||||
});
|
||||
}
|
||||
|
||||
tbody.querySelectorAll('.tp-row').forEach(bindRow);
|
||||
renumber();
|
||||
|
||||
addBtn?.addEventListener('click', () => {
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const tr = frag.querySelector('tr');
|
||||
tbody.appendChild(frag);
|
||||
if (tr) bindRow(tr);
|
||||
renumber();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
hidden.innerHTML = '';
|
||||
const values = [];
|
||||
tbody.querySelectorAll('.tp-select').forEach(sel => {
|
||||
const v = (sel.value || '').toString().trim();
|
||||
if (!v) return;
|
||||
if (values.includes(v)) return;
|
||||
values.push(v);
|
||||
});
|
||||
values.forEach(v => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'operation_ids';
|
||||
inp.value = v;
|
||||
inp.setAttribute('form', formId);
|
||||
hidden.appendChild(inp);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -25,24 +25,29 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="container-fluid p-0">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" enctype="multipart/form-data" id="product-info-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="hidden" name="trail" value="{{ request.GET.trail|default:'' }}">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Тип</label>
|
||||
<input class="form-control bg-body text-body border-secondary" value="{{ entity.get_entity_type_display }}" disabled>
|
||||
<div class="mt-1"><span class="badge bg-secondary">{{ entity.get_entity_type_display }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Обозначение</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="drawing_number" value="{{ entity.drawing_number }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Заполнен</label>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="passport_filled" id="pf" {% if entity.passport_filled %}checked{% endif %} {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -50,11 +55,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Наименование</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="name" value="{{ entity.name }}" {% if not can_edit %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ГОСТ/ТУ</label>
|
||||
<input class="form-control bg-body text-body border-secondary" name="gost" value="{% if passport %}{{ passport.gost }}{% endif %}" {% if not can_edit %}disabled{% endif %}>
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Чертёж/паспорт (PDF)</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="pdf_main" accept="application/pdf" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.pdf_main %}
|
||||
@@ -83,7 +83,15 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DXF/IGES/STEP</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="dxf_file" accept=".dxf,.iges,.igs,.step,.stp" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.dxf_file %}
|
||||
<div class="small mt-1"><a href="{{ entity.dxf_file.url }}" target="_blank">Открыть текущий</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Картинка</label>
|
||||
<input class="form-control bg-body text-body border-secondary" type="file" name="preview" accept="image/*" {% if not can_edit %}disabled{% endif %}>
|
||||
{% if entity.preview %}
|
||||
@@ -100,7 +108,7 @@
|
||||
</form>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="mt-3">
|
||||
<div class="mt-3" id="techprocess-editor" data-form-id="product-info-form">
|
||||
<div class="fw-bold mb-2">Операции техпроцесса</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
@@ -109,69 +117,154 @@
|
||||
<tr class="table-custom-header">
|
||||
<th style="width:70px;">№</th>
|
||||
<th>Операция</th>
|
||||
<th>Цех</th>
|
||||
<th data-sort="false" class="text-end"></th>
|
||||
<th style="width:220px;" class="text-end" data-sort="false">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eo in entity_ops %}
|
||||
<tr>
|
||||
<td class="text-muted">{{ eo.seq }}</td>
|
||||
<td class="fw-bold">{{ eo.operation.name }}</td>
|
||||
<td class="small text-muted">{% if eo.operation.workshop %}{{ eo.operation.workshop.name }}{% else %}—{% endif %}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↑</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move_entity_operation">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">↓</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'product_info' entity.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_entity_operation">
|
||||
<input type="hidden" name="entity_operation_id" value="{{ eo.id }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button class="btn btn-outline-secondary" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Операции не добавлены</td></tr>
|
||||
{% endfor %}
|
||||
<tbody id="tpRows">
|
||||
{% if selected_operation_ids %}
|
||||
{% for op_id in selected_operation_ids %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}" {% if op.id == op_id %}selected{% endif %}>{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for i in '1234'|make_list %}
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx">{{ forloop.counter }}</td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'product_info' entity.id %}" class="row g-2 mt-2">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_entity_operation">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-accent btn-sm" id="tpAddRow">+ строка</button>
|
||||
<button class="btn btn-outline-accent" type="submit" form="product-info-form">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-10">
|
||||
<label class="form-label">Добавить операцию</label>
|
||||
<select class="form-select bg-body text-body border-secondary" name="operation_id">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end justify-content-end">
|
||||
<button class="btn btn-outline-accent" type="submit">+</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="tpHidden"></div>
|
||||
|
||||
<template id="tpRowTemplate">
|
||||
<tr class="tp-row">
|
||||
<td class="text-muted tp-idx"></td>
|
||||
<td>
|
||||
<select class="form-select bg-body text-body border-secondary tp-select">
|
||||
<option value="">— выбери —</option>
|
||||
{% for op in operations %}
|
||||
<option value="{{ op.id }}">{{ op.name }}{% if op.workshop %} ({{ op.workshop.name }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary tp-up">↑</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-down">↓</button>
|
||||
<button type="button" class="btn btn-outline-secondary tp-del">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const root = document.getElementById('techprocess-editor');
|
||||
if (!root) return;
|
||||
|
||||
const formId = root.getAttribute('data-form-id') || 'product-info-form';
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
const tbody = document.getElementById('tpRows');
|
||||
const addBtn = document.getElementById('tpAddRow');
|
||||
const hidden = document.getElementById('tpHidden');
|
||||
const tpl = document.getElementById('tpRowTemplate');
|
||||
|
||||
function renumber() {
|
||||
tbody.querySelectorAll('.tp-row').forEach((tr, idx) => {
|
||||
const cell = tr.querySelector('.tp-idx');
|
||||
if (cell) cell.textContent = String(idx + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRow(tr) {
|
||||
tr.querySelector('.tp-up')?.addEventListener('click', () => {
|
||||
const prev = tr.previousElementSibling;
|
||||
if (prev) tbody.insertBefore(tr, prev);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-down')?.addEventListener('click', () => {
|
||||
const next = tr.nextElementSibling;
|
||||
if (next) tbody.insertBefore(next, tr);
|
||||
renumber();
|
||||
});
|
||||
tr.querySelector('.tp-del')?.addEventListener('click', () => {
|
||||
tr.remove();
|
||||
renumber();
|
||||
});
|
||||
}
|
||||
|
||||
tbody.querySelectorAll('.tp-row').forEach(bindRow);
|
||||
renumber();
|
||||
|
||||
addBtn?.addEventListener('click', () => {
|
||||
const frag = tpl.content.cloneNode(true);
|
||||
const tr = frag.querySelector('tr');
|
||||
tbody.appendChild(frag);
|
||||
if (tr) bindRow(tr);
|
||||
renumber();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
hidden.innerHTML = '';
|
||||
const values = [];
|
||||
tbody.querySelectorAll('.tp-select').forEach(sel => {
|
||||
const v = (sel.value || '').toString().trim();
|
||||
if (!v) return;
|
||||
if (values.includes(v)) return;
|
||||
values.push(v);
|
||||
});
|
||||
values.forEach(v => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'operation_ids';
|
||||
inp.value = v;
|
||||
inp.setAttribute('form', formId);
|
||||
hidden.appendChild(inp);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
182
shiftflow/templates/shiftflow/shipping.html
Normal file
182
shiftflow/templates/shiftflow/shipping.html
Normal file
@@ -0,0 +1,182 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card shadow border-secondary mb-3">
|
||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
||||
<h3 class="text-accent mb-0"><i class="bi bi-truck me-2"></i>Отгрузка</h3>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'planning' %}"><i class="bi bi-arrow-left me-1"></i>Назад</a>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-10">
|
||||
<label class="form-label">Сделка</label>
|
||||
<select class="form-select bg-body text-body border-secondary" name="deal_id">
|
||||
<option value="">— выбери —</option>
|
||||
{% for d in deals %}
|
||||
<option value="{{ d.id }}" {% if selected_deal_id == d.id %}selected{% endif %}>
|
||||
№{{ d.number }}{% if d.company %} · {{ d.company.name }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-grid">
|
||||
<button class="btn btn-outline-accent" type="submit">Показать</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if selected_deal_id %}
|
||||
<hr class="border-secondary my-4">
|
||||
|
||||
<form method="post" id="shipForm">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="deal_id" value="{{ selected_deal_id }}">
|
||||
|
||||
<div class="fw-bold mb-2">Позиции к отгрузке (по сделке)</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Позиция</th>
|
||||
<th class="text-center" style="width:140px;">Есть на складе</th>
|
||||
<th class="text-center" style="width:180px;">К отгрузке</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in entity_rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold">{{ r.entity.drawing_number|default:"—" }} {{ r.entity.name }}</div>
|
||||
<div class="small text-muted">{{ r.entity.get_entity_type_display }}</div>
|
||||
</td>
|
||||
<td class="text-center">{{ r.available }}</td>
|
||||
<td class="text-center">
|
||||
<input
|
||||
class="form-control bg-body text-body border-secondary ship-qty"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
name="ent_{{ r.entity.id }}"
|
||||
value="0"
|
||||
data-label="{{ r.entity.drawing_number|default:'—' }} {{ r.entity.name }}"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="text-center text-muted py-4">Позиции сделки не найдены</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if material_rows %}
|
||||
<div class="fw-bold mt-4 mb-2">Давальческий материал</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="table-custom-header">
|
||||
<th>Материал</th>
|
||||
<th class="text-center" style="width:140px;">Есть на складе</th>
|
||||
<th class="text-center" style="width:180px;">К отгрузке</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in material_rows %}
|
||||
<tr>
|
||||
<td class="fw-bold">{{ r.material.full_name|default:r.material.name }}</td>
|
||||
<td class="text-center">{{ r.available }}</td>
|
||||
<td class="text-center">
|
||||
<input
|
||||
class="form-control bg-body text-body border-secondary ship-qty"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
name="mat_{{ r.material.id }}"
|
||||
value="0"
|
||||
data-label="{{ r.material.full_name|default:r.material.name }}"
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-outline-accent" data-bs-toggle="modal" data-bs-target="#shipConfirmModal" id="shipOpenConfirm">
|
||||
Отгрузить
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-secondary" disabled>Отгрузить</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="shipConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">Подтвердить отгрузку</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="small text-muted mb-2">Проверь итоговый список к отгрузке:</div>
|
||||
<div id="shipSummary" class="border border-secondary rounded p-2"></div>
|
||||
<div id="shipSummaryEmpty" class="text-muted d-none">Нечего отгружать (везде 0).</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-outline-accent" id="shipConfirmBtn">Принять отгрузку</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const openBtn = document.getElementById('shipOpenConfirm');
|
||||
const confirmBtn = document.getElementById('shipConfirmBtn');
|
||||
const summary = document.getElementById('shipSummary');
|
||||
const empty = document.getElementById('shipSummaryEmpty');
|
||||
const inputs = Array.from(document.querySelectorAll('#shipForm .ship-qty'));
|
||||
|
||||
function buildSummary() {
|
||||
const rows = [];
|
||||
inputs.forEach(inp => {
|
||||
const raw = (inp.value || '').toString().trim();
|
||||
if (!raw) return;
|
||||
const val = parseFloat(raw.replace(',', '.'));
|
||||
if (!val || val <= 0) return;
|
||||
const label = inp.getAttribute('data-label') || '';
|
||||
rows.push({ label, val });
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
summary.innerHTML = '';
|
||||
empty.classList.remove('d-none');
|
||||
if (confirmBtn) confirmBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
empty.classList.add('d-none');
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
|
||||
summary.innerHTML = rows.map(r => {
|
||||
return `<div class="d-flex justify-content-between gap-2"><div>${r.label}</div><div class="fw-bold">${r.val}</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
openBtn?.addEventListener('click', () => buildSummary());
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="text-muted mt-3">Выбери сделку, чтобы сформировать список к отгрузке.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -169,20 +169,7 @@
|
||||
<i class="bi bi-arrow-left-right me-1"></i>Переместить
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#transferModal"
|
||||
data-mode="ship"
|
||||
data-stock-item-id="{{ it.id }}"
|
||||
data-stock-item-name="{% if it.material_id %}{{ it.material.full_name }}{% elif it.entity_id %}{{ it.entity }}{% else %}—{% endif %}"
|
||||
data-from-location="{{ it.location }}"
|
||||
data-from-location-id="{{ it.location_id }}"
|
||||
data-max="{{ it.quantity }}"
|
||||
>
|
||||
<i class="bi bi-truck me-1"></i>Отгрузка
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small">только просмотр</span>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user