Открыл админу изделия, доработал списание
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:
@@ -63,3 +63,12 @@
|
|||||||
- Для массовых операций избегай N+1:
|
- Для массовых операций избегай N+1:
|
||||||
- select_related / prefetch_related
|
- select_related / prefetch_related
|
||||||
- bulk update/create там, где это безопасно.
|
- bulk update/create там, где это безопасно.
|
||||||
|
|
||||||
|
|
||||||
|
Правило для новых внутренних функций (как договор):
|
||||||
|
|
||||||
|
- Всегда берём логгер logger = logging.getLogger('mes')
|
||||||
|
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
|
||||||
|
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
|
||||||
|
- На важных шагах — logger.info('fn:step ...', детали)
|
||||||
|
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше
|
||||||
3
TODO.md
3
TODO.md
@@ -11,3 +11,6 @@
|
|||||||
|
|
||||||
## Потребность (Материалы)
|
## Потребность (Материалы)
|
||||||
- Пересмотреть расчёт потребности: уйти от м²/мм, формировать пачки DXF по материалам/толщинам и прокат по длинам (для nesting/ручного расчёта).
|
- Пересмотреть расчёт потребности: уйти от м²/мм, формировать пачки DXF по материалам/толщинам и прокат по длинам (для nesting/ручного расчёта).
|
||||||
|
|
||||||
|
## Изделия (Сборка)
|
||||||
|
- Проработать интерфейс сборки изделия: редактирование состава, паспорт узла, маршруты, сварные швы, быстрые переходы по уровням.
|
||||||
@@ -13,6 +13,8 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import environ
|
import environ
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@@ -178,6 +180,46 @@ STATICFILES_DIRS = [BASE_DIR / 'static']
|
|||||||
MEDIA_URL = 'media/'
|
MEDIA_URL = 'media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
# Логирование (единый файл на сервер)
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
try:
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'simple': {
|
||||||
|
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'mes_file': {
|
||||||
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
|
'level': 'INFO',
|
||||||
|
'filename': str(LOG_DIR / 'mes.log'),
|
||||||
|
'maxBytes': 5 * 1024 * 1024,
|
||||||
|
'backupCount': 3,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'formatter': 'simple',
|
||||||
|
},
|
||||||
|
'console': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'level': 'INFO',
|
||||||
|
'formatter': 'simple',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'mes': {
|
||||||
|
'handlers': ['mes_file', 'console'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Куда переходим после логина
|
# Куда переходим после логина
|
||||||
LOGIN_REDIRECT_URL = '/' # Куда идем после входа
|
LOGIN_REDIRECT_URL = '/' # Куда идем после входа
|
||||||
LOGOUT_REDIRECT_URL = '/' # Куда идем после выхода
|
LOGOUT_REDIRECT_URL = '/' # Куда идем после выхода
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
import logging
|
||||||
|
|
||||||
from shiftflow.models import (
|
from shiftflow.models import (
|
||||||
CuttingSession,
|
CuttingSession,
|
||||||
@@ -9,7 +10,8 @@ from shiftflow.models import (
|
|||||||
ShiftItem,
|
ShiftItem,
|
||||||
)
|
)
|
||||||
from shiftflow.services.sessions import close_cutting_session
|
from shiftflow.services.sessions import close_cutting_session
|
||||||
from warehouse.models import StockItem
|
|
||||||
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -22,12 +24,15 @@ def apply_closing(
|
|||||||
consumptions: dict[int, float],
|
consumptions: dict[int, float],
|
||||||
remnants: list[dict],
|
remnants: list[dict],
|
||||||
) -> None:
|
) -> 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(
|
items = list(
|
||||||
Item.objects.select_for_update(of=('self',))
|
Item.objects.select_for_update(of=('self',))
|
||||||
.select_related('task', 'task__deal', 'task__material', 'machine')
|
.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)
|
.filter(id__in=list(item_actions.keys()), machine_id=machine_id, status='work', task__material_id=material_id)
|
||||||
)
|
)
|
||||||
if not items:
|
if not items:
|
||||||
|
logger.error('apply_closing:no_items machine=%s material=%s', machine_id, material_id)
|
||||||
raise RuntimeError('Не найдено пунктов сменки для закрытия.')
|
raise RuntimeError('Не найдено пунктов сменки для закрытия.')
|
||||||
|
|
||||||
report = CuttingSession.objects.create(
|
report = CuttingSession.objects.create(
|
||||||
@@ -38,6 +43,8 @@ def apply_closing(
|
|||||||
is_closed=False,
|
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:
|
for it in items:
|
||||||
spec = item_actions.get(it.id) or {}
|
spec = item_actions.get(it.id) or {}
|
||||||
action = (spec.get('action') or '').strip()
|
action = (spec.get('action') or '').strip()
|
||||||
@@ -59,6 +66,7 @@ def apply_closing(
|
|||||||
|
|
||||||
ShiftItem.objects.create(session=report, task=it.task, quantity_fact=fact)
|
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():
|
for stock_item_id, qty in consumptions.items():
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
continue
|
continue
|
||||||
@@ -69,6 +77,7 @@ def apply_closing(
|
|||||||
quantity=float(qty),
|
quantity=float(qty),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info('apply_closing:remnants_count=%s', len(remnants))
|
||||||
for r in remnants:
|
for r in remnants:
|
||||||
qty = float(r.get('quantity') or 0)
|
qty = float(r.get('quantity') or 0)
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
@@ -82,6 +91,7 @@ def apply_closing(
|
|||||||
unique_id=None,
|
unique_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info('apply_closing:close_session id=%s', report.id)
|
||||||
close_cutting_session(report.id)
|
close_cutting_session(report.id)
|
||||||
|
|
||||||
for it in items:
|
for it in items:
|
||||||
@@ -118,3 +128,5 @@ def apply_closing(
|
|||||||
status='leftover',
|
status='leftover',
|
||||||
is_synced_1c=False,
|
is_synced_1c=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info('apply_closing:done report=%s', report.id)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
import logging
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from manufacturing.models import ProductEntity
|
from manufacturing.models import ProductEntity
|
||||||
@@ -12,6 +13,8 @@ from shiftflow.models import (
|
|||||||
)
|
)
|
||||||
from warehouse.models import StockItem
|
from warehouse.models import StockItem
|
||||||
|
|
||||||
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def close_cutting_session(session_id: int) -> None:
|
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)
|
- для каждого ShiftItem создаём StockItem(entity=..., location=machine.location, quantity=quantity_fact)
|
||||||
- если использованный материал не совпадает с planned_material КД -> material_substitution=True
|
- если использованный материал не совпадает с planned_material КД -> material_substitution=True
|
||||||
"""
|
"""
|
||||||
|
logger.info('close_cutting_session:start id=%s', session_id)
|
||||||
session = (
|
session = (
|
||||||
CuttingSession.objects.select_for_update(of=('self',))
|
CuttingSession.objects.select_for_update(of=('self',))
|
||||||
.select_related(
|
.select_related(
|
||||||
@@ -66,6 +70,8 @@ def close_cutting_session(session_id: int) -> None:
|
|||||||
|
|
||||||
if c.stock_item_id:
|
if c.stock_item_id:
|
||||||
si = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=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:
|
if not si.material_id:
|
||||||
raise RuntimeError('В списании сырья указана позиция склада без material.')
|
raise RuntimeError('В списании сырья указана позиция склада без material.')
|
||||||
|
|
||||||
@@ -80,6 +86,7 @@ def close_cutting_session(session_id: int) -> None:
|
|||||||
si.is_archived = True
|
si.is_archived = True
|
||||||
si.archived_at = timezone.now()
|
si.archived_at = timezone.now()
|
||||||
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
|
si.save(update_fields=['quantity', 'is_archived', 'archived_at'])
|
||||||
|
logger.info('close_cutting_session:archived stock_item=%s', si.id)
|
||||||
else:
|
else:
|
||||||
si.save(update_fields=['quantity'])
|
si.save(update_fields=['quantity'])
|
||||||
|
|
||||||
@@ -107,7 +114,10 @@ def close_cutting_session(session_id: int) -> None:
|
|||||||
need -= take
|
need -= take
|
||||||
|
|
||||||
if si.quantity == 0:
|
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:
|
else:
|
||||||
si.save(update_fields=['quantity'])
|
si.save(update_fields=['quantity'])
|
||||||
|
|
||||||
@@ -118,6 +128,7 @@ def close_cutting_session(session_id: int) -> None:
|
|||||||
raise RuntimeError('Не заполнено списание сырья: добавь строки «Списание сырья» или укажи legacy поле «Взятый материал».')
|
raise RuntimeError('Не заполнено списание сырья: добавь строки «Списание сырья» или укажи legacy поле «Взятый материал».')
|
||||||
|
|
||||||
used = StockItem.objects.select_for_update(of=('self',)).select_related('material', 'location').get(pk=session.used_stock_item_id)
|
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:
|
if not used.material_id:
|
||||||
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).')
|
raise RuntimeError('Взятый материал должен ссылаться на сырьё (material), а не на готовую деталь (entity).')
|
||||||
|
|
||||||
@@ -132,6 +143,7 @@ def close_cutting_session(session_id: int) -> None:
|
|||||||
used.is_archived = True
|
used.is_archived = True
|
||||||
used.archived_at = timezone.now()
|
used.archived_at = timezone.now()
|
||||||
used.save(update_fields=['quantity', 'is_archived', 'archived_at'])
|
used.save(update_fields=['quantity', 'is_archived', 'archived_at'])
|
||||||
|
logger.info('close_cutting_session:archived used=%s', used.id)
|
||||||
else:
|
else:
|
||||||
used.save(update_fields=['quantity'])
|
used.save(update_fields=['quantity'])
|
||||||
|
|
||||||
@@ -194,3 +206,4 @@ def close_cutting_session(session_id: int) -> None:
|
|||||||
|
|
||||||
session.is_closed = True
|
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>Единица</th>
|
||||||
|
<th>Размеры</th>
|
||||||
<th>Доступно</th>
|
<th>Доступно</th>
|
||||||
<th data-sort="false">Использовано</th>
|
<th data-sort="false">Использовано</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -107,6 +108,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ s }}</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>{{ s.quantity }}</td>
|
||||||
<td style="max-width:140px;">
|
<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 %}>
|
<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) на сервере.
|
Здесь настраиваем и обслуживаем генерацию превью DXF (PNG) на сервере.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="card border-secondary mb-3">
|
<div class="card border-secondary mb-3">
|
||||||
<div class="card-header border-secondary py-2">
|
<div class="card-header border-secondary py-2">
|
||||||
<strong>DXF</strong>
|
<strong>DXF</strong>
|
||||||
@@ -70,8 +71,8 @@
|
|||||||
<button type="submit" class="btn btn-outline-warning" name="action" value="cancel_job">
|
<button type="submit" class="btn btn-outline-warning" name="action" value="cancel_job">
|
||||||
<i class="bi bi-stop-circle me-2"></i>Прервать
|
<i class="bi bi-stop-circle me-2"></i>Прервать
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_log">
|
<button type="submit" class="btn btn-outline-secondary" name="action" value="clear_dxf_job_log">
|
||||||
<i class="bi bi-eraser me-2"></i>Очистить лог
|
<i class="bi bi-eraser me-2"></i>Очистить лог DXF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,6 +85,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if messages %}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" onclick="window.print()">
|
<button class="btn btn-sm btn-outline-secondary" onclick="window.print()">
|
||||||
Печать
|
Печать
|
||||||
</button>
|
</button>
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'registry' %}?{{ request.GET.urlencode }}">
|
<button class="btn btn-sm btn-outline-secondary" onclick="window.close()">
|
||||||
Назад
|
Закрыть
|
||||||
</a>
|
</button>
|
||||||
<div class="ms-auto small text-muted">
|
<div class="ms-auto small text-muted">
|
||||||
{{ printed_at|date:"d.m.Y H:i" }}
|
{{ printed_at|date:"d.m.Y H:i" }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -268,10 +268,18 @@
|
|||||||
<label class="form-label small text-muted">Название</label>
|
<label class="form-label small text-muted">Название</label>
|
||||||
<input type="text" class="form-control border-secondary" id="categoryName">
|
<input type="text" class="form-control border-secondary" id="categoryName">
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-0">
|
<div class="mb-3">
|
||||||
<label class="form-label small text-muted">ГОСТ</label>
|
<label class="form-label small text-muted">ГОСТ</label>
|
||||||
<input type="text" class="form-control border-secondary" id="categoryGost">
|
<input type="text" class="form-control border-secondary" id="categoryGost">
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer border-secondary">
|
<div class="modal-footer border-secondary">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
@@ -584,6 +592,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
id: categoryId.value,
|
id: categoryId.value,
|
||||||
name: categoryName.value,
|
name: categoryName.value,
|
||||||
gost_standard: categoryGost.value,
|
gost_standard: categoryGost.value,
|
||||||
|
form_factor: document.getElementById('categoryFormFactor') ? document.getElementById('categoryFormFactor').value : 'other',
|
||||||
};
|
};
|
||||||
const data = await postForm('{% url "material_category_upsert" %}', payload);
|
const data = await postForm('{% url "material_category_upsert" %}', payload);
|
||||||
upsertSelectOption(materialCategory, data.id, data.label);
|
upsertSelectOption(materialCategory, data.id, data.label);
|
||||||
|
|||||||
@@ -45,10 +45,20 @@
|
|||||||
<div class="row g-3 mt-1">
|
<div class="row g-3 mt-1">
|
||||||
<div class="col-lg-4">
|
<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.consumed %}
|
{% if card.report.consumptions.all %}
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
{% for k,v in card.consumed.items %}
|
{% for c in card.report.consumptions.all %}
|
||||||
<li>{{ k }}: {{ v }}</li>
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -61,7 +71,7 @@
|
|||||||
{% if card.produced %}
|
{% if card.produced %}
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
{% for k,v in card.produced.items %}
|
{% for k,v in card.produced.items %}
|
||||||
<li>{{ k }}: {{ v }}</li>
|
<li>{{ k }}: {{ v }} шт</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -70,11 +80,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<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 %}
|
{% if card.remnants %}
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
{% for k,v in card.remnants.items %}
|
{% for k,v in card.remnants.items %}
|
||||||
<li>{{ k }}: {{ v }}</li>
|
<li>{{ k }}: {{ v }} шт</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -755,6 +755,26 @@ class MaintenanceStatusView(LoginRequiredMixin, View):
|
|||||||
class MaintenanceView(LoginRequiredMixin, TemplateView):
|
class MaintenanceView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = 'shiftflow/maintenance.html'
|
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):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
profile = getattr(request.user, 'profile', None)
|
profile = getattr(request.user, 'profile', None)
|
||||||
role = profile.role if profile else ('admin' if request.user.is_superuser else 'operator')
|
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 = super().get_context_data(**kwargs)
|
||||||
context['user_role'] = 'admin'
|
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()
|
s = _get_dxf_preview_settings()
|
||||||
context['dxf_settings'] = s
|
context['dxf_settings'] = s
|
||||||
@@ -817,9 +841,18 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
|
|||||||
messages.success(request, 'Остановка запрошена.')
|
messages.success(request, 'Остановка запрошена.')
|
||||||
return redirect('maintenance')
|
return redirect('maintenance')
|
||||||
|
|
||||||
|
if action == 'refresh_log':
|
||||||
|
return redirect('maintenance')
|
||||||
|
|
||||||
if action == 'clear_log':
|
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()
|
job = DxfPreviewJob.objects.order_by('-id').first()
|
||||||
if not job:
|
if not job:
|
||||||
messages.info(request, 'Логов нет.')
|
messages.info(request, 'Логов нет.')
|
||||||
@@ -831,9 +864,9 @@ class MaintenanceView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
_dxf_job_log_path(job.id).open('wb').close()
|
_dxf_job_log_path(job.id).open('wb').close()
|
||||||
messages.success(request, 'Лог очищен.')
|
messages.success(request, 'Лог DXF-генерации очищен.')
|
||||||
except Exception:
|
except Exception:
|
||||||
messages.error(request, 'Не удалось очистить лог.')
|
messages.error(request, 'Не удалось очистить лог DXF-генерации.')
|
||||||
return redirect('maintenance')
|
return redirect('maintenance')
|
||||||
|
|
||||||
if action != 'update_previews':
|
if action != 'update_previews':
|
||||||
@@ -1177,6 +1210,7 @@ class MaterialCategoryUpsertView(LoginRequiredMixin, View):
|
|||||||
category_id = request.POST.get('id')
|
category_id = request.POST.get('id')
|
||||||
name = (request.POST.get('name') or '').strip()
|
name = (request.POST.get('name') or '').strip()
|
||||||
gost_standard = (request.POST.get('gost_standard') 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:
|
if not name:
|
||||||
return JsonResponse({'error': 'name_required'}, status=400)
|
return JsonResponse({'error': 'name_required'}, status=400)
|
||||||
@@ -1188,6 +1222,8 @@ class MaterialCategoryUpsertView(LoginRequiredMixin, View):
|
|||||||
category, _ = MaterialCategory.objects.get_or_create(name=name)
|
category, _ = MaterialCategory.objects.get_or_create(name=name)
|
||||||
|
|
||||||
category.gost_standard = gost_standard
|
category.gost_standard = gost_standard
|
||||||
|
if form_factor in ['sheet', 'bar', 'other']:
|
||||||
|
category.form_factor = form_factor
|
||||||
category.save()
|
category.save()
|
||||||
return JsonResponse({'id': category.id, 'label': category.name})
|
return JsonResponse({'id': category.id, 'label': category.name})
|
||||||
|
|
||||||
@@ -1809,6 +1845,25 @@ class ClosingView(LoginRequiredMixin, TemplateView):
|
|||||||
messages.error(request, 'Выбери хотя бы один пункт сменки и режим закрытия (полностью/частично).')
|
messages.error(request, 'Выбери хотя бы один пункт сменки и режим закрытия (полностью/частично).')
|
||||||
return redirect(f"{reverse_lazy('closing')}?machine_id={machine_id}&material_id={material_id}")
|
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):
|
class ProductsView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = 'shiftflow/products.html'
|
template_name = 'shiftflow/products.html'
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,observer' %}
|
{% if user_role in 'admin,technologist,observer' or request.user.is_superuser %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.resolver_match.url_name == 'products' %}active{% endif %}" href="{% url 'products' %}">Изделия</a>
|
<a class="nav-link {% if request.resolver_match.url_name == 'products' %}active{% endif %}" href="{% url 'products' %}">Изделия</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user