Добавил страницу отгрузки, подправил логику генерации сменных заданий. Организовал редактирование позици сделок
All checks were successful
Deploy MES Core / deploy (push) Successful in 29s

This commit is contained in:
2026-04-14 07:27:54 +03:00
parent 69edd3fa97
commit 49e9080d0e
14 changed files with 2056 additions and 564 deletions

View File

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

View 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

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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 %}

View File

@@ -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>

View File

@@ -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'),

View File

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

View File

@@ -26,6 +26,12 @@
<a class="nav-link {% if request.resolver_match.url_name == 'planning' or request.resolver_match.url_name == 'planning_deal' %}active{% endif %}" href="{% url 'planning' %}">Сделки</a>
</li>
{% if user_role in 'admin,clerk,manager,prod_head,director,observer,technologist' %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'shipping' %}active{% endif %}" href="{% url 'shipping' %}">Отгрузка</a>
</li>
{% endif %}
{% if user_role in 'admin,technologist,master,clerk' %}
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'planning_stages' %}active{% endif %}" href="{% url 'planning_stages' %}">План</a>