Открыл админу изделия, доработал списание
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s

This commit is contained in:
2026-04-07 12:57:43 +03:00
parent a238c83b04
commit 86215c9fa8
12 changed files with 206 additions and 23 deletions

View File

@@ -1,5 +1,6 @@
from django.db import transaction
from django.utils import timezone
import logging
from shiftflow.models import (
CuttingSession,
@@ -9,7 +10,8 @@ from shiftflow.models import (
ShiftItem,
)
from shiftflow.services.sessions import close_cutting_session
from warehouse.models import StockItem
logger = logging.getLogger('mes')
@transaction.atomic
@@ -22,12 +24,15 @@ def apply_closing(
consumptions: dict[int, float],
remnants: list[dict],
) -> None:
logger.info('apply_closing:start user=%s machine=%s material=%s items=%s consumptions=%s remnants=%s', user_id, machine_id, material_id, list(item_actions.keys()), list(consumptions.keys()), len(remnants))
items = list(
Item.objects.select_for_update(of=('self',))
.select_related('task', 'task__deal', 'task__material', 'machine')
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status='work', task__material_id=material_id)
)
if not items:
logger.error('apply_closing:no_items machine=%s material=%s', machine_id, material_id)
raise RuntimeError('Не найдено пунктов сменки для закрытия.')
report = CuttingSession.objects.create(
@@ -38,6 +43,8 @@ def apply_closing(
is_closed=False,
)
logger.info('apply_closing:report_created id=%s', report.id)
logger.info('apply_closing:update_items start items=%s', [it.id for it in items])
for it in items:
spec = item_actions.get(it.id) or {}
action = (spec.get('action') or '').strip()
@@ -59,6 +66,7 @@ def apply_closing(
ShiftItem.objects.create(session=report, task=it.task, quantity_fact=fact)
logger.info('apply_closing:consumption_count=%s', len(consumptions))
for stock_item_id, qty in consumptions.items():
if qty <= 0:
continue
@@ -69,6 +77,7 @@ def apply_closing(
quantity=float(qty),
)
logger.info('apply_closing:remnants_count=%s', len(remnants))
for r in remnants:
qty = float(r.get('quantity') or 0)
if qty <= 0:
@@ -82,6 +91,7 @@ def apply_closing(
unique_id=None,
)
logger.info('apply_closing:close_session id=%s', report.id)
close_cutting_session(report.id)
for it in items:
@@ -117,4 +127,6 @@ def apply_closing(
quantity_fact=0,
status='leftover',
is_synced_1c=False,
)
)
logger.info('apply_closing:done report=%s', report.id)

View File

@@ -1,4 +1,5 @@
from django.db import transaction
import logging
from django.utils import timezone
from manufacturing.models import ProductEntity
@@ -12,6 +13,8 @@ from shiftflow.models import (
)
from warehouse.models import StockItem
logger = logging.getLogger('mes')
@transaction.atomic
def close_cutting_session(session_id: int) -> None:
@@ -26,6 +29,7 @@ def close_cutting_session(session_id: int) -> None:
- для каждого ShiftItem создаём StockItem(entity=..., location=machine.location, quantity=quantity_fact)
- если использованный материал не совпадает с planned_material КД -> material_substitution=True
"""
logger.info('close_cutting_session:start id=%s', session_id)
session = (
CuttingSession.objects.select_for_update(of=('self',))
.select_related(
@@ -66,6 +70,8 @@ def close_cutting_session(session_id: int) -> None:
if c.stock_item_id:
si = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=c.stock_item_id)
logger.info('close_cutting_session:consume stock_item=%s qty=%s before=%s', si.id, c.quantity, si.quantity)
if not si.material_id:
raise RuntimeError('В списании сырья указана позиция склада без material.')
@@ -80,6 +86,7 @@ def close_cutting_session(session_id: int) -> None:
si.is_archived = True
si.archived_at = timezone.now()
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
logger.info('close_cutting_session:archived stock_item=%s', si.id)
else:
si.save(update_fields=['quantity'])
@@ -107,7 +114,10 @@ def close_cutting_session(session_id: int) -> None:
need -= take
if si.quantity == 0:
si.delete()
si.is_archived = True
si.archived_at = timezone.now()
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
logger.info('close_cutting_session:archived stock_item=%s', si.id)
else:
si.save(update_fields=['quantity'])
@@ -118,6 +128,7 @@ def close_cutting_session(session_id: int) -> None:
raise RuntimeError('Не заполнено списание сырья: добавь строки «Списание сырья» или укажи legacy поле «Взятый материал».')
used = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=session.used_stock_item_id)
logger.info('close_cutting_session:used stock_item=%s before=%s', used.id, used.quantity)
if not used.material_id:
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).')
@@ -132,6 +143,7 @@ def close_cutting_session(session_id: int) -> None:
used.is_archived = True
used.archived_at = timezone.now()
used.save(update_fields=['quantity', 'is_archived', 'archived_at'])
logger.info('close_cutting_session:archived used=%s', used.id)
else:
used.save(update_fields=['quantity'])
@@ -193,4 +205,5 @@ def close_cutting_session(session_id: int) -> None:
ProductionReportStockResult.objects.create(report=session, stock_item=created, kind='remnant')
session.is_closed = True
session.save(update_fields=["is_closed"])
session.save(update_fields=["is_closed"])
logger.info('close_cutting_session:done id=%s', session_id)

View File

@@ -91,6 +91,7 @@
<th>Поступление</th>
<th>Сделка</th>
<th>Единица</th>
<th>Размеры</th>
<th>Доступно</th>
<th data-sort="false">Использовано</th>
</tr>
@@ -107,6 +108,15 @@
{% endif %}
</td>
<td>{{ s }}</td>
<td>
{% if s.current_length and s.current_width %}
{{ s.current_length|floatformat:"-g" }} × {{ s.current_width|floatformat:"-g" }} мм
{% elif s.current_length %}
{{ s.current_length|floatformat:"-g" }} мм
{% else %}
{% endif %}
</td>
<td>{{ s.quantity }}</td>
<td style="max-width:140px;">
<input class="form-control form-control-sm border-secondary" name="consume_{{ s.id }}" placeholder="0" {% if not can_edit %}disabled{% endif %}>

View File

@@ -12,6 +12,7 @@
Здесь настраиваем и обслуживаем генерацию превью DXF (PNG) на сервере.
</div>
<div class="card border-secondary mb-3">
<div class="card-header border-secondary py-2">
<strong>DXF</strong>
@@ -70,8 +71,8 @@
<button type="submit" class="btn btn-outline-warning" name="action" value="cancel_job">
<i class="bi bi-stop-circle me-2"></i>Прервать
</button>
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_log">
<i class="bi bi-eraser me-2"></i>Очистить лог
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_dxf_job_log">
<i class="bi bi-eraser me-2"></i>Очистить лог DXF
</button>
</div>
@@ -84,6 +85,25 @@
</div>
</div>
<div class="card border-secondary mb-3">
<div class="card-header border-secondary py-2">
<strong>Логи</strong>
</div>
<div class="card-body">
<div class="small text-muted mb-2">Файл: {{ log_path|default:"—" }}</div>
<pre class="border border-secondary rounded p-2 mb-3" style="max-height: 260px; overflow:auto; white-space: pre-wrap;">{{ log_tail|default:"" }}</pre>
<form method="post" class="d-flex gap-2">
{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary" name="action" value="refresh_log">
<i class="bi bi-arrow-repeat me-2"></i>Обновить
</button>
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_log">
<i class="bi bi-eraser me-2"></i>Очистить
</button>
</form>
</div>
</div>
{% if messages %}
<div class="mt-3">
{% for message in messages %}

View File

@@ -30,9 +30,9 @@
<button class="btn btn-sm btn-outline-secondary" onclick="window.print()">
Печать
</button>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'registry' %}?{{ request.GET.urlencode }}">
Назад
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="window.close()">
Закрыть
</button>
<div class="ms-auto small text-muted">
{{ printed_at|date:"d.m.Y H:i" }}
</div>

View File

@@ -268,10 +268,18 @@
<label class="form-label small text-muted">Название</label>
<input type="text" class="form-control border-secondary" id="categoryName">
</div>
<div class="mb-0">
<div class="mb-3">
<label class="form-label small text-muted">ГОСТ</label>
<input type="text" class="form-control border-secondary" id="categoryGost">
</div>
<div class="mb-0">
<label class="form-label small text-muted">Форма</label>
<select class="form-select border-secondary" id="categoryFormFactor">
<option value="sheet">Лист</option>
<option value="bar">Прокат/хлыст</option>
<option value="other" selected>Прочее</option>
</select>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
@@ -584,6 +592,7 @@ document.addEventListener('DOMContentLoaded', function () {
id: categoryId.value,
name: categoryName.value,
gost_standard: categoryGost.value,
form_factor: document.getElementById('categoryFormFactor') ? document.getElementById('categoryFormFactor').value : 'other',
};
const data = await postForm('{% url "material_category_upsert" %}', payload);
upsertSelectOption(materialCategory, data.id, data.label);

View File

@@ -45,10 +45,20 @@
<div class="row g-3 mt-1">
<div class="col-lg-4">
<div class="small text-muted fw-bold mb-1">Списано</div>
{% if card.consumed %}
{% if card.report.consumptions.all %}
<ul class="mb-0">
{% for k,v in card.consumed.items %}
<li>{{ k }}: {{ v }}</li>
{% for c in card.report.consumptions.all %}
{% if c.stock_item_id and c.stock_item.material_id %}
<li>
{{ c.stock_item.material.full_name|default:c.stock_item.material.name }}
({% if c.stock_item.current_length and c.stock_item.current_width %}{{ c.stock_item.current_length|floatformat:"-g" }}×{{ c.stock_item.current_width|floatformat:"-g" }}{% elif c.stock_item.current_length %}{{ c.stock_item.current_length|floatformat:"-g" }}{% else %}—{% endif %})
{{ c.quantity|floatformat:"-g" }} шт
</li>
{% elif c.material_id %}
<li>{{ c.material }} {{ c.quantity|floatformat:"-g" }} шт</li>
{% else %}
<li>— {{ c.quantity|floatformat:"-g" }} шт</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
@@ -61,7 +71,7 @@
{% if card.produced %}
<ul class="mb-0">
{% for k,v in card.produced.items %}
<li>{{ k }}: {{ v }}</li>
<li>{{ k }}: {{ v }} шт</li>
{% endfor %}
</ul>
{% else %}
@@ -70,11 +80,11 @@
</div>
<div class="col-lg-4">
<div class="small text-muted fw-bold mb-1">ДО</div>
<div class="small text-muted fw-bold mb-1">Остаток ДО</div>
{% if card.remnants %}
<ul class="mb-0">
{% for k,v in card.remnants.items %}
<li>{{ k }}: {{ v }}</li>
<li>{{ k }}: {{ v }} шт</li>
{% endfor %}
</ul>
{% else %}

View File

@@ -755,6 +755,26 @@ class MaintenanceStatusView(LoginRequiredMixin, View):
class MaintenanceView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/maintenance.html'
def _server_log_path(self):
p = (django_settings.BASE_DIR / 'logs' / 'mes.log')
return p
def _read_tail(self, path, max_bytes: int = 20000) -> str:
try:
if not path.exists():
return ''
size = path.stat().st_size
start = max(0, size - max_bytes)
with path.open('rb') as f:
f.seek(start)
data = f.read()
try:
return data.decode('utf-8', errors='replace')
except Exception:
return str(data)
except Exception:
return ''
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')
@@ -767,6 +787,10 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
context = super().get_context_data(**kwargs)
context['user_role'] = 'admin'
log_path = self._server_log_path()
context['log_path'] = str(log_path)
context['log_tail'] = self._read_tail(log_path)
# Подтягиваем текущие настройки генерации превью, чтобы отрисовать форму.
s = _get_dxf_preview_settings()
context['dxf_settings'] = s
@@ -817,9 +841,18 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
messages.success(request, 'Остановка запрошена.')
return redirect('maintenance')
if action == 'refresh_log':
return redirect('maintenance')
if action == 'clear_log':
# Очистка лог-файла последней задачи. Во время выполнения не трогаем,
# потому что процесс может держать открытый дескриптор файла.
try:
self._server_log_path().open('wb').close()
messages.success(request, 'Лог очищен.')
except Exception:
messages.error(request, 'Не удалось очистить лог.')
return redirect('maintenance')
if action == 'clear_dxf_job_log':
job = DxfPreviewJob.objects.order_by('-id').first()
if not job:
messages.info(request, 'Логов нет.')
@@ -831,9 +864,9 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
try:
_dxf_job_log_path(job.id).open('wb').close()
messages.success(request, 'Лог очищен.')
messages.success(request, 'Лог DXF-генерации очищен.')
except Exception:
messages.error(request, 'Не удалось очистить лог.')
messages.error(request, 'Не удалось очистить лог DXF-генерации.')
return redirect('maintenance')
if action != 'update_previews':
@@ -1177,6 +1210,7 @@ class MaterialCategoryUpsertView(LoginRequiredMixin, View):
category_id = request.POST.get('id')
name = (request.POST.get('name') or '').strip()
gost_standard = (request.POST.get('gost_standard') or '').strip()
form_factor = (request.POST.get('form_factor') or '').strip() or 'other'
if not name:
return JsonResponse({'error': 'name_required'}, status=400)
@@ -1188,6 +1222,8 @@ class MaterialCategoryUpsertView(LoginRequiredMixin, View):
category, _ = MaterialCategory.objects.get_or_create(name=name)
category.gost_standard = gost_standard
if form_factor in ['sheet', 'bar', 'other']:
category.form_factor = form_factor
category.save()
return JsonResponse({'id': category.id, 'label': category.name})
@@ -1809,6 +1845,25 @@ class ClosingView(LoginRequiredMixin, TemplateView):
messages.error(request, 'Выбери хотя бы один пункт сменки и режим закрытия (полностью/частично).')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
if not consumptions:
messages.error(request, 'Заполни списание: укажи, какие единицы на складе использованы и в каком количестве.')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
try:
apply_closing(
user_id=request.user.id,
machine_id=int(machine_id),
material_id=int(material_id),
item_actions=item_actions,
consumptions=consumptions,
remnants=remnants,
)
messages.success(request, 'Закрытие выполнено.')
except Exception as e:
messages.error(request, f'Ошибка закрытия: {e}')
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
class ProductsView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/products.html'