Открыл админу изделия, доработал списание
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
All checks were successful
Deploy MES Core / deploy (push) Successful in 10s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user