Доработал закрытие, техоперации автоматом переключаются, добавил выгрузку сменных заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 14s
All checks were successful
Deploy MES Core / deploy (push) Successful in 14s
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -10,9 +10,13 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
- Журнал отгрузки: список документов перемещения на «Склад отгруженных позиций».
|
- Журнал отгрузки: список документов перемещения на «Склад отгруженных позиций».
|
||||||
|
- Реестр заданий: выгрузка сменного задания в архив ZIP (HTML как в «Печать», TXT и manifest) с прикреплением файлов КД по материалам.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Версия приложения: 0.9.3.
|
||||||
- Отгрузка: можно добавлять несколько сделок в одну сессию отгрузки, выбирать позиции и подтверждать общий список.
|
- Отгрузка: можно добавлять несколько сделок в одну сессию отгрузки, выбирать позиции и подтверждать общий список.
|
||||||
|
- Печать реестра WorkItem: добавлен вывод длины заготовки для позиций без чертежей (типовой случай ленточнопилы); добавлена опциональная плашка «Сформировано …» для экспортируемого HTML.
|
||||||
|
- Сменные задания: кнопка «Комплектация» показывается только на первой операции сборки/изделия, добавлена кнопка «Доп. расходы» (в разработке).
|
||||||
- Журнал отгрузки: добавлены фильтр по периоду (по умолчанию 2 недели) и поиск по сделкам (номер/описание/заказчик), убран столбец «Куда».
|
- Журнал отгрузки: добавлены фильтр по периоду (по умолчанию 2 недели) и поиск по сделкам (номер/описание/заказчик), убран столбец «Куда».
|
||||||
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
|
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
|
||||||
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
|
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
|
||||||
@@ -24,7 +28,14 @@
|
|||||||
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса или материала: показывается модалка и отдельная страница со списком проблемных позиций.
|
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса или материала: показывается модалка и отдельная страница со списком проблемных позиций.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Выгрузка сменного задания: имена файлов КД формируются с суффиксом nXX как количеством деталей к изготовлению (а не порядковым номером файла).
|
||||||
|
- Закрытие первой операции детали: запрещено закрытие без выбранного поста/станка (чтобы не было закрытия без списания сырья).
|
||||||
|
- Закрытие сборки/изделия: после закрытия выполняется возврат в реестр.
|
||||||
|
- Закрытие последующих операций (например, покраски): после полного закрытия выполняется возврат в реестр.
|
||||||
|
- Закрытие последующих операций: при нехватке изделий на складе участка показывается окно перемещения на участок.
|
||||||
- Починено закрытие сборок/изделий на странице «Закрыть сборку»: выбор поста доступен и сохраняется, списание/выпуск выполняются.
|
- Починено закрытие сборок/изделий на странице «Закрыть сборку»: выбор поста доступен и сохраняется, списание/выпуск выполняются.
|
||||||
|
- Закрытие сборки/изделия: при закрытии первой операции корректно продвигается текущая операция техпроцесса (переход на следующую, например на покраску).
|
||||||
|
- Закрытие операций: при полном выполнении операции автоматически продвигается маршрут и создаётся следующее сменное задание (например, после резки — гибка, после сварки — покраска).
|
||||||
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
|
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
|
||||||
- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий.
|
- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий.
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ if os.path.exists(env_file):
|
|||||||
|
|
||||||
# читаем переменную окружения
|
# читаем переменную окружения
|
||||||
ENV_TYPE = os.getenv('ENV_TYPE', 'local')
|
ENV_TYPE = os.getenv('ENV_TYPE', 'local')
|
||||||
APP_VERSION = '0.8.9'
|
APP_VERSION = '0.9.3'
|
||||||
|
|
||||||
# Настройки безопасности
|
# Настройки безопасности
|
||||||
# DEBUG будет True везде, кроме сервера
|
# DEBUG будет True везде, кроме сервера
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import logging
|
import logging
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q, Case, When, Value, IntegerField
|
from django.db.models import Q, Case, When, Value, IntegerField, Sum
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from warehouse.models import StockItem
|
from warehouse.models import StockItem
|
||||||
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult
|
from shiftflow.models import (
|
||||||
|
DealEntityProgress,
|
||||||
|
DealItem,
|
||||||
|
ProductionTask,
|
||||||
|
WorkItem,
|
||||||
|
CuttingSession,
|
||||||
|
ProductionReportConsumption,
|
||||||
|
ProductionReportStockResult,
|
||||||
|
)
|
||||||
from shiftflow.services.bom_explosion import _build_bom_graph
|
from shiftflow.services.bom_explosion import _build_bom_graph
|
||||||
from shiftflow.services.kitting import get_work_location_for_workitem
|
from shiftflow.services.kitting import get_work_location_for_workitem
|
||||||
from manufacturing.models import EntityOperation
|
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
|
||||||
|
from manufacturing.models import EntityOperation, Operation
|
||||||
|
|
||||||
|
|
||||||
def get_first_operation_id(entity_id: int) -> int | None:
|
def get_first_operation_id(entity_id: int) -> int | None:
|
||||||
@@ -189,6 +199,59 @@ def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> boo
|
|||||||
if workitem.quantity_done >= workitem.quantity_plan:
|
if workitem.quantity_done >= workitem.quantity_plan:
|
||||||
workitem.status = 'done'
|
workitem.status = 'done'
|
||||||
workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
|
workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
|
||||||
|
advance_progress_and_generate_next_workitem(workitem_id=int(workitem.id))
|
||||||
|
|
||||||
|
target_qty = None
|
||||||
|
if getattr(workitem, 'delivery_batch_id', None):
|
||||||
|
target_qty = ProductionTask.objects.filter(
|
||||||
|
deal_id=workitem.deal_id,
|
||||||
|
delivery_batch_id=workitem.delivery_batch_id,
|
||||||
|
entity_id=workitem.entity_id,
|
||||||
|
).values_list('quantity_ordered', flat=True).first()
|
||||||
|
else:
|
||||||
|
di = DealItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).first()
|
||||||
|
target_qty = int(di.quantity) if di else None
|
||||||
|
|
||||||
|
if target_qty is not None:
|
||||||
|
op_code = ''
|
||||||
|
if getattr(workitem, 'operation_id', None):
|
||||||
|
op_code = (Operation.objects.filter(pk=workitem.operation_id).values_list('code', flat=True).first() or '').strip()
|
||||||
|
if not op_code:
|
||||||
|
op_code = (workitem.stage or '').strip()
|
||||||
|
|
||||||
|
if op_code:
|
||||||
|
progress = (
|
||||||
|
DealEntityProgress.objects.select_for_update(of=('self',))
|
||||||
|
.filter(
|
||||||
|
deal_id=workitem.deal_id,
|
||||||
|
delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None),
|
||||||
|
entity_id=workitem.entity_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not progress:
|
||||||
|
progress = DealEntityProgress.objects.create(
|
||||||
|
deal_id=workitem.deal_id,
|
||||||
|
delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None),
|
||||||
|
entity_id=workitem.entity_id,
|
||||||
|
current_seq=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
cur = int(progress.current_seq or 1)
|
||||||
|
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=workitem.entity_id, seq=cur).first()
|
||||||
|
if cur_eo and cur_eo.operation and (cur_eo.operation.code or '').strip() == op_code:
|
||||||
|
wi_qs = WorkItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).filter(
|
||||||
|
Q(operation__code=op_code) | Q(stage=op_code)
|
||||||
|
)
|
||||||
|
if getattr(workitem, 'delivery_batch_id', None):
|
||||||
|
wi_qs = wi_qs.filter(delivery_batch_id=workitem.delivery_batch_id)
|
||||||
|
else:
|
||||||
|
wi_qs = wi_qs.filter(delivery_batch_id__isnull=True)
|
||||||
|
|
||||||
|
total_done = wi_qs.aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
|
||||||
|
if int(total_done or 0) >= int(target_qty):
|
||||||
|
progress.current_seq = cur + 1
|
||||||
|
progress.save(update_fields=['current_seq'])
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',
|
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from shiftflow.models import (
|
|||||||
ShiftItem,
|
ShiftItem,
|
||||||
)
|
)
|
||||||
from shiftflow.services.sessions import close_cutting_session
|
from shiftflow.services.sessions import close_cutting_session
|
||||||
|
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
|
||||||
|
|
||||||
logger = logging.getLogger('mes')
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
@@ -208,6 +209,7 @@ def apply_closing_workitems(
|
|||||||
else:
|
else:
|
||||||
wi.status = 'planned'
|
wi.status = 'planned'
|
||||||
wi.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
|
wi.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
|
||||||
|
advance_progress_and_generate_next_workitem(workitem_id=int(wi.id))
|
||||||
|
|
||||||
for stock_item_id, qty in consumptions.items():
|
for stock_item_id, qty in consumptions.items():
|
||||||
if qty and float(qty) > 0:
|
if qty and float(qty) > 0:
|
||||||
|
|||||||
134
shiftflow/services/route_flow.py
Normal file
134
shiftflow/services/route_flow.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q, Sum
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from manufacturing.models import EntityOperation
|
||||||
|
from shiftflow.models import DealEntityProgress, DealItem, ProductionTask, WorkItem
|
||||||
|
|
||||||
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
|
|
||||||
|
def _workitem_op_code(wi: WorkItem) -> str:
|
||||||
|
if getattr(wi, 'operation_id', None) and getattr(wi, 'operation', None):
|
||||||
|
code = (wi.operation.code or '').strip()
|
||||||
|
if code:
|
||||||
|
return code
|
||||||
|
return (wi.stage or '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _target_qty_for_workitem(wi: WorkItem) -> int | None:
|
||||||
|
if getattr(wi, 'delivery_batch_id', None):
|
||||||
|
qty = (
|
||||||
|
ProductionTask.objects.filter(
|
||||||
|
deal_id=wi.deal_id,
|
||||||
|
delivery_batch_id=wi.delivery_batch_id,
|
||||||
|
entity_id=wi.entity_id,
|
||||||
|
)
|
||||||
|
.values_list('quantity_ordered', flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return int(qty) if qty is not None else None
|
||||||
|
|
||||||
|
di = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
|
||||||
|
return int(di.quantity) if di else None
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def advance_progress_and_generate_next_workitem(*, workitem_id: int) -> int | None:
|
||||||
|
wi = (
|
||||||
|
WorkItem.objects.select_for_update(of=('self',))
|
||||||
|
.select_related('operation')
|
||||||
|
.filter(id=int(workitem_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not wi:
|
||||||
|
return None
|
||||||
|
|
||||||
|
op_code = _workitem_op_code(wi)
|
||||||
|
if not op_code:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_qty = _target_qty_for_workitem(wi)
|
||||||
|
if target_qty is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
progress, _ = DealEntityProgress.objects.select_for_update(of=('self',)).get_or_create(
|
||||||
|
deal_id=wi.deal_id,
|
||||||
|
delivery_batch_id=(int(wi.delivery_batch_id) if getattr(wi, 'delivery_batch_id', None) else None),
|
||||||
|
entity_id=wi.entity_id,
|
||||||
|
defaults={'current_seq': 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
cur = int(progress.current_seq or 1)
|
||||||
|
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=wi.entity_id, seq=cur).first()
|
||||||
|
if not cur_eo or not cur_eo.operation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur_code = (cur_eo.operation.code or '').strip()
|
||||||
|
if cur_code != op_code:
|
||||||
|
return None
|
||||||
|
|
||||||
|
wi_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code))
|
||||||
|
if getattr(wi, 'delivery_batch_id', None):
|
||||||
|
wi_qs = wi_qs.filter(delivery_batch_id=wi.delivery_batch_id)
|
||||||
|
else:
|
||||||
|
wi_qs = wi_qs.filter(delivery_batch_id__isnull=True)
|
||||||
|
|
||||||
|
total_done = wi_qs.aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
|
||||||
|
if int(total_done or 0) < int(target_qty):
|
||||||
|
return None
|
||||||
|
|
||||||
|
progress.current_seq = cur + 1
|
||||||
|
progress.save(update_fields=['current_seq'])
|
||||||
|
|
||||||
|
next_eo = (
|
||||||
|
EntityOperation.objects.select_related('operation', 'operation__workshop')
|
||||||
|
.filter(entity_id=wi.entity_id, seq=int(progress.current_seq))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not next_eo or not next_eo.operation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
next_op = next_eo.operation
|
||||||
|
next_code = (next_op.code or '').strip()
|
||||||
|
|
||||||
|
planned_qs = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id)
|
||||||
|
if getattr(wi, 'delivery_batch_id', None):
|
||||||
|
planned_qs = planned_qs.filter(delivery_batch_id=wi.delivery_batch_id)
|
||||||
|
else:
|
||||||
|
planned_qs = planned_qs.filter(delivery_batch_id__isnull=True)
|
||||||
|
|
||||||
|
planned_total = planned_qs.filter(Q(operation_id=next_op.id) | Q(operation__code=next_code) | Q(stage=next_code)).aggregate(
|
||||||
|
s=Coalesce(Sum('quantity_plan'), 0)
|
||||||
|
)['s']
|
||||||
|
|
||||||
|
remaining_to_plan = max(0, int(target_qty) - int(planned_total or 0))
|
||||||
|
if remaining_to_plan <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
created = WorkItem.objects.create(
|
||||||
|
deal_id=wi.deal_id,
|
||||||
|
delivery_batch_id=(int(wi.delivery_batch_id) if getattr(wi, 'delivery_batch_id', None) else None),
|
||||||
|
entity_id=wi.entity_id,
|
||||||
|
operation_id=next_op.id,
|
||||||
|
stage=(next_code or next_op.name or '')[:32],
|
||||||
|
workshop_id=(int(next_op.workshop_id) if getattr(next_op, 'workshop_id', None) else None),
|
||||||
|
machine_id=None,
|
||||||
|
quantity_plan=int(remaining_to_plan),
|
||||||
|
quantity_done=0,
|
||||||
|
status='planned',
|
||||||
|
date=timezone.localdate(),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
'route_flow:created_next_workitem id=%s deal_id=%s batch_id=%s entity_id=%s op=%s qty=%s',
|
||||||
|
created.id,
|
||||||
|
created.deal_id,
|
||||||
|
getattr(created, 'delivery_batch_id', None),
|
||||||
|
created.entity_id,
|
||||||
|
next_code or '',
|
||||||
|
created.quantity_plan,
|
||||||
|
)
|
||||||
|
return int(created.id)
|
||||||
449
shiftflow/services/workitem_registry_export.py
Normal file
449
shiftflow/services/workitem_registry_export.py
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
"""
|
||||||
|
Сервис выгрузки сменного задания из реестра WorkItem в ZIP.
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
- отдать оператору офлайн-пакет без сохранения на диск (всё в памяти);
|
||||||
|
- внутри архива:
|
||||||
|
- HTML (строго тот же шаблон, что и кнопка «Печать»),
|
||||||
|
- TXT (текстовая версия),
|
||||||
|
- manifest.txt,
|
||||||
|
- файлы КД (DXF/IGES/STEP/PDF) разложенные по папкам материала.
|
||||||
|
- выгружаем только сделки в статусе «В работе».
|
||||||
|
|
||||||
|
Особенности:
|
||||||
|
- опциональная транслитерация имён файлов/папок для совместимости с “железом” и Windows-путями;
|
||||||
|
- для типового случая ленточнопилы (нет чертежей/IGES) в HTML/TXT должна быть видна длина заготовки,
|
||||||
|
если она хранится в ProductEntity.blank_length_mm.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from shiftflow.models import WorkItem
|
||||||
|
|
||||||
|
logger = logging.getLogger("mes")
|
||||||
|
|
||||||
|
|
||||||
|
_RU_TRANSLIT_MAP = {
|
||||||
|
"а": "a",
|
||||||
|
"б": "b",
|
||||||
|
"в": "v",
|
||||||
|
"г": "g",
|
||||||
|
"д": "d",
|
||||||
|
"е": "e",
|
||||||
|
"ё": "yo",
|
||||||
|
"ж": "zh",
|
||||||
|
"з": "z",
|
||||||
|
"и": "i",
|
||||||
|
"й": "y",
|
||||||
|
"к": "k",
|
||||||
|
"л": "l",
|
||||||
|
"м": "m",
|
||||||
|
"н": "n",
|
||||||
|
"о": "o",
|
||||||
|
"п": "p",
|
||||||
|
"р": "r",
|
||||||
|
"с": "s",
|
||||||
|
"т": "t",
|
||||||
|
"у": "u",
|
||||||
|
"ф": "f",
|
||||||
|
"х": "h",
|
||||||
|
"ц": "ts",
|
||||||
|
"ч": "ch",
|
||||||
|
"ш": "sh",
|
||||||
|
"щ": "sch",
|
||||||
|
"ъ": "",
|
||||||
|
"ы": "y",
|
||||||
|
"ь": "",
|
||||||
|
"э": "e",
|
||||||
|
"ю": "yu",
|
||||||
|
"я": "ya",
|
||||||
|
}
|
||||||
|
|
||||||
|
_INVALID_WIN_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1F]')
|
||||||
|
_MULTISPACE_RE = re.compile(r"\s+")
|
||||||
|
_MULTI_DOTS_RE = re.compile(r"\.{2,}")
|
||||||
|
|
||||||
|
|
||||||
|
def _transliterate_ru(s: str) -> str:
|
||||||
|
"""
|
||||||
|
Простейшая транслитерация RU->LAT для имён файлов.
|
||||||
|
|
||||||
|
Почему не slugify:
|
||||||
|
- стандартный slugify без сторонних библиотек не транслитерирует кириллицу (вырезает),
|
||||||
|
а нам важно сохранить читаемость.
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
for ch in (s or ""):
|
||||||
|
low = ch.lower()
|
||||||
|
if low in _RU_TRANSLIT_MAP:
|
||||||
|
t = _RU_TRANSLIT_MAP[low]
|
||||||
|
out.append(t.upper() if ch.isupper() and t else t)
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_fs_component(s: str) -> str:
|
||||||
|
"""
|
||||||
|
Делает строку безопасной как имя файла/папки (под Windows).
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- вычищаем недопустимые символы;
|
||||||
|
- нормализуем пробелы;
|
||||||
|
- не допускаем пустых имён;
|
||||||
|
- убираем точки/пробелы на конце (Windows).
|
||||||
|
"""
|
||||||
|
s = (s or "").strip()
|
||||||
|
s = _INVALID_WIN_CHARS_RE.sub("_", s)
|
||||||
|
s = _MULTISPACE_RE.sub(" ", s).strip()
|
||||||
|
s = _MULTI_DOTS_RE.sub(".", s)
|
||||||
|
s = s.strip(" .")
|
||||||
|
return s or "unnamed"
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_translit_and_sanitize(s: str, *, translit: bool) -> str:
|
||||||
|
"""
|
||||||
|
Применяет транслитерацию (если включено) и затем sanitizing.
|
||||||
|
"""
|
||||||
|
if translit:
|
||||||
|
s = _transliterate_ru(s)
|
||||||
|
return _sanitize_fs_component(s)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ExportContext:
|
||||||
|
"""
|
||||||
|
Контекст выгрузки для форматов HTML/TXT и manifest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
printed_at: datetime
|
||||||
|
print_date: datetime.date
|
||||||
|
export_bar_text: str
|
||||||
|
only_work_deals: bool
|
||||||
|
translit: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_print_date_from_request(request) -> datetime.date:
|
||||||
|
"""
|
||||||
|
Определяет «дату печати» так же, как это обычно делается в печатных формах:
|
||||||
|
берём конец периода, иначе начало, иначе сегодня.
|
||||||
|
"""
|
||||||
|
start_date = (request.GET.get("start_date") or "").strip()
|
||||||
|
end_date = (request.GET.get("end_date") or "").strip()
|
||||||
|
raw = end_date or start_date
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return timezone.localdate()
|
||||||
|
return timezone.localdate()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_workitems_by_ids(*, workitem_ids: list[int]) -> list[WorkItem]:
|
||||||
|
"""Возвращает список WorkItem по id с нужными связями для рендера/экспорта."""
|
||||||
|
ids = [int(x) for x in (workitem_ids or []) if int(x) > 0]
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
return list(
|
||||||
|
WorkItem.objects.select_related(
|
||||||
|
'deal',
|
||||||
|
'entity',
|
||||||
|
'entity__planned_material',
|
||||||
|
'operation',
|
||||||
|
'machine',
|
||||||
|
'workshop',
|
||||||
|
)
|
||||||
|
.filter(id__in=ids)
|
||||||
|
.order_by('-date', 'deal__number', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_workitems(rows: list[WorkItem]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Группирует WorkItem так же, как печатная форма:
|
||||||
|
ключ = (цех, станок/пост), значение = список работ.
|
||||||
|
"""
|
||||||
|
groups: dict[tuple[str, str], dict] = {}
|
||||||
|
for wi in rows:
|
||||||
|
ws_label = wi.workshop.name if wi.workshop else "—"
|
||||||
|
m_label = wi.machine.name if wi.machine else ""
|
||||||
|
key = (ws_label, m_label)
|
||||||
|
g = groups.get(key)
|
||||||
|
if not g:
|
||||||
|
g = {"workshop": ws_label, "machine": m_label, "items": []}
|
||||||
|
groups[key] = g
|
||||||
|
g["items"].append(wi)
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _render_html(*, request, groups: list[dict], ctx: _ExportContext) -> str:
|
||||||
|
"""
|
||||||
|
Рендерит HTML строго тем же шаблоном, что и кнопка «Печать».
|
||||||
|
"""
|
||||||
|
context = {
|
||||||
|
"groups": groups,
|
||||||
|
"printed_at": ctx.printed_at,
|
||||||
|
"print_date": ctx.print_date,
|
||||||
|
"export_bar_text": ctx.export_bar_text,
|
||||||
|
}
|
||||||
|
return render_to_string("shiftflow/registry_workitems_print.html", context, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_txt(*, groups: list[dict], ctx: _ExportContext) -> str:
|
||||||
|
"""
|
||||||
|
Формирует TXT-версию выгрузки.
|
||||||
|
|
||||||
|
Принцип:
|
||||||
|
- читаемо в блокноте;
|
||||||
|
- фиксированный порядок колонок;
|
||||||
|
- добавляем длину заготовки, если у сущности нет файла КД и задан blank_length_mm.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("СМЕННОЕ ЗАДАНИЕ (выгрузка из MES)")
|
||||||
|
lines.append(f"{ctx.export_bar_text}")
|
||||||
|
if ctx.only_work_deals:
|
||||||
|
lines.append("Ограничение: только сделки в статусе «В работе».")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for g in groups:
|
||||||
|
ws = (g.get("workshop") or "—").strip()
|
||||||
|
m = (g.get("machine") or "").strip()
|
||||||
|
header = f"Цех: {ws}"
|
||||||
|
if m:
|
||||||
|
header += f" | Станок/пост: {m}"
|
||||||
|
lines.append(header)
|
||||||
|
lines.append("Дата\tСделка\tОперация\tПозиция\tМатериал\tПлан\tФакт")
|
||||||
|
|
||||||
|
for wi in (g.get("items") or []):
|
||||||
|
deal_no = getattr(getattr(wi, "deal", None), "number", "") or "-"
|
||||||
|
op_name = (getattr(getattr(wi, "operation", None), "name", "") or getattr(wi, "stage", "") or "—").strip()
|
||||||
|
|
||||||
|
ent = getattr(wi, "entity", None)
|
||||||
|
dno = (getattr(ent, "drawing_number", "") or "").strip()
|
||||||
|
ename = (getattr(ent, "name", "") or "").strip()
|
||||||
|
pos = f"{dno} {ename}".strip() if dno or ename else "—"
|
||||||
|
|
||||||
|
has_kd = bool(getattr(ent, "dxf_file", None))
|
||||||
|
length_mm = getattr(ent, "blank_length_mm", None)
|
||||||
|
if (not has_kd) and (not dno) and length_mm:
|
||||||
|
pos = f"{pos} | Длина: {int(round(float(length_mm)))} мм"
|
||||||
|
|
||||||
|
mat = getattr(ent, "planned_material", None)
|
||||||
|
mat_label = "—"
|
||||||
|
if mat:
|
||||||
|
mat_label = (getattr(mat, "full_name", "") or getattr(mat, "name", "") or "—").strip()
|
||||||
|
|
||||||
|
dt = wi.date.strftime("%d.%m.%y") if getattr(wi, "date", None) else ""
|
||||||
|
plan = str(int(getattr(wi, "quantity_plan", 0) or 0))
|
||||||
|
fact = str(int(getattr(wi, "quantity_done", 0) or 0))
|
||||||
|
|
||||||
|
lines.append(f"{dt}\t{deal_no}\t{op_name}\t{pos}\t{mat_label}\t{plan}\t{fact}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_entity_kd_files(rows: list[WorkItem]) -> list[tuple[str, object]]:
|
||||||
|
"""
|
||||||
|
Собирает список файлов КД по всем уникальным сущностям в выгрузке.
|
||||||
|
|
||||||
|
Возвращаем список (kind, file_field):
|
||||||
|
- kind нужен для подсказок/manifest, но имя файла берём из исходного поля.
|
||||||
|
"""
|
||||||
|
seen: set[int] = set()
|
||||||
|
out: list[tuple[str, object]] = []
|
||||||
|
for wi in rows:
|
||||||
|
ent = getattr(wi, "entity", None)
|
||||||
|
if not ent:
|
||||||
|
continue
|
||||||
|
eid = int(getattr(ent, "id", 0) or 0)
|
||||||
|
if eid <= 0 or eid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(eid)
|
||||||
|
|
||||||
|
dxf = getattr(ent, "dxf_file", None)
|
||||||
|
pdf = getattr(ent, "pdf_main", None)
|
||||||
|
if dxf:
|
||||||
|
out.append(("dxf_iges_step", dxf))
|
||||||
|
if pdf:
|
||||||
|
out.append(("pdf", pdf))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_write_filefield(zf: zipfile.ZipFile, arc_path: str, file_field) -> int:
|
||||||
|
"""
|
||||||
|
Записывает FileField в архив.
|
||||||
|
|
||||||
|
Возвращает размер в байтах (для manifest).
|
||||||
|
"""
|
||||||
|
file_field.open("rb")
|
||||||
|
try:
|
||||||
|
data = file_field.read()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
file_field.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
zf.writestr(arc_path, data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
|
||||||
|
def build_workitem_registry_export_zip(
|
||||||
|
*,
|
||||||
|
request,
|
||||||
|
workitem_ids: list[int],
|
||||||
|
translit: bool,
|
||||||
|
only_work_deals: bool,
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Главная точка входа сервиса.
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
- zip_bytes: содержимое архива (в памяти)
|
||||||
|
- filename: имя файла для Content-Disposition
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"fn:start build_workitem_registry_export_zip user_id=%s workitems=%s translit=%s only_work_deals=%s",
|
||||||
|
getattr(getattr(request, "user", None), "id", None),
|
||||||
|
len(workitem_ids or []),
|
||||||
|
int(bool(translit)),
|
||||||
|
int(bool(only_work_deals)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
printed_at = timezone.now()
|
||||||
|
print_date = _parse_print_date_from_request(request)
|
||||||
|
export_bar_text = f"Сформировано: {printed_at.strftime('%d.%m.%Y %H:%M')}"
|
||||||
|
|
||||||
|
ctx = _ExportContext(
|
||||||
|
printed_at=printed_at,
|
||||||
|
print_date=print_date,
|
||||||
|
export_bar_text=export_bar_text,
|
||||||
|
only_work_deals=bool(only_work_deals),
|
||||||
|
translit=bool(translit),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = _fetch_workitems_by_ids(workitem_ids=list(workitem_ids or []))
|
||||||
|
|
||||||
|
groups = _group_workitems(rows)
|
||||||
|
html = _render_html(request=request, groups=groups, ctx=ctx)
|
||||||
|
txt = _render_txt(groups=groups, ctx=ctx)
|
||||||
|
|
||||||
|
safe_ts = printed_at.strftime("%Y%m%d_%H%M%S")
|
||||||
|
base_name = f"shift_task_{safe_ts}"
|
||||||
|
if translit:
|
||||||
|
base_name = _maybe_translit_and_sanitize(base_name, translit=True)
|
||||||
|
zip_filename = f"{base_name}.zip"
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
manifest_lines: list[str] = []
|
||||||
|
manifest_lines.append("MES export manifest")
|
||||||
|
manifest_lines.append(f"generated_at={printed_at.isoformat()}")
|
||||||
|
manifest_lines.append(f"only_work_deals={int(bool(only_work_deals))}")
|
||||||
|
manifest_lines.append(f"translit={int(bool(translit))}")
|
||||||
|
manifest_lines.append(f"rows={len(rows)}")
|
||||||
|
manifest_lines.append(f"groups={len(groups)}")
|
||||||
|
manifest_lines.append("")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
html_name = "shift_task.html"
|
||||||
|
txt_name = "shift_task.txt"
|
||||||
|
man_name = "manifest.txt"
|
||||||
|
|
||||||
|
zf.writestr(html_name, html.encode("utf-8"))
|
||||||
|
zf.writestr(txt_name, txt.encode("utf-8"))
|
||||||
|
|
||||||
|
kd_entries: list[str] = []
|
||||||
|
|
||||||
|
entities: dict[int, object] = {}
|
||||||
|
entity_plan_qty: dict[int, int] = {}
|
||||||
|
|
||||||
|
for wi in rows:
|
||||||
|
ent = getattr(wi, 'entity', None)
|
||||||
|
if not ent:
|
||||||
|
continue
|
||||||
|
|
||||||
|
eid = int(getattr(ent, 'id', 0) or 0)
|
||||||
|
if eid <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if eid not in entities:
|
||||||
|
entities[eid] = ent
|
||||||
|
|
||||||
|
qty = int(getattr(wi, 'quantity_plan', 0) or 0)
|
||||||
|
entity_plan_qty[eid] = int(entity_plan_qty.get(eid, 0) or 0) + max(0, qty)
|
||||||
|
|
||||||
|
for eid, ent in entities.items():
|
||||||
|
mat = getattr(ent, 'planned_material', None)
|
||||||
|
mat_label = 'Без_материала'
|
||||||
|
if mat:
|
||||||
|
mat_label = (getattr(mat, 'full_name', '') or getattr(mat, 'name', '') or 'Без_материала').strip() or 'Без_материала'
|
||||||
|
|
||||||
|
folder = _maybe_translit_and_sanitize(mat_label, translit=bool(translit))
|
||||||
|
|
||||||
|
dno = (getattr(ent, 'drawing_number', '') or '').strip()
|
||||||
|
ename = (getattr(ent, 'name', '') or '').strip()
|
||||||
|
base = f"{dno} {ename}".strip() or f"entity_{getattr(ent, 'id', '')}"
|
||||||
|
base = _maybe_translit_and_sanitize(base, translit=bool(translit))
|
||||||
|
|
||||||
|
plan_n = int(entity_plan_qty.get(int(eid), 0) or 0)
|
||||||
|
suffix = f" n{plan_n}" if plan_n > 0 else ""
|
||||||
|
|
||||||
|
for ff in [getattr(ent, 'dxf_file', None), getattr(ent, 'pdf_main', None)]:
|
||||||
|
if not ff:
|
||||||
|
continue
|
||||||
|
|
||||||
|
src_name = (getattr(ff, 'name', '') or '').strip()
|
||||||
|
ext = ''
|
||||||
|
if '.' in src_name:
|
||||||
|
ext = '.' + src_name.split('.')[-1].lower()
|
||||||
|
|
||||||
|
arc_name = f"{base}{suffix}{ext}"
|
||||||
|
arc_name = _maybe_translit_and_sanitize(arc_name, translit=False)
|
||||||
|
|
||||||
|
arc_path = f"kd/{folder}/{arc_name}"
|
||||||
|
try:
|
||||||
|
size = _zip_write_filefield(zf, arc_path, ff)
|
||||||
|
kd_entries.append(f"{arc_path}\t{size}")
|
||||||
|
except Exception:
|
||||||
|
logger.exception('fn:error export_kd_file arc_path=%s', arc_path)
|
||||||
|
kd_entries.append(f"{arc_path}\tERROR")
|
||||||
|
|
||||||
|
manifest_lines.append("files:")
|
||||||
|
manifest_lines.append(f"- {html_name}")
|
||||||
|
manifest_lines.append(f"- {txt_name}")
|
||||||
|
manifest_lines.append(f"- {man_name}")
|
||||||
|
manifest_lines.append("")
|
||||||
|
manifest_lines.append("kd_files:")
|
||||||
|
if kd_entries:
|
||||||
|
manifest_lines.extend(kd_entries)
|
||||||
|
else:
|
||||||
|
manifest_lines.append("(none)")
|
||||||
|
|
||||||
|
zf.writestr(man_name, ("\n".join(manifest_lines).strip() + "\n").encode("utf-8"))
|
||||||
|
|
||||||
|
buf.seek(0)
|
||||||
|
data = buf.getvalue()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"fn:done build_workitem_registry_export_zip bytes=%s rows=%s groups=%s",
|
||||||
|
len(data),
|
||||||
|
len(rows),
|
||||||
|
len(groups),
|
||||||
|
)
|
||||||
|
return data, zip_filename
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("fn:error build_workitem_registry_export_zip")
|
||||||
|
raise
|
||||||
@@ -8,8 +8,16 @@
|
|||||||
<h3 class="text-accent mb-0">
|
<h3 class="text-accent mb-0">
|
||||||
<i class="bi bi-check2-square me-2"></i>Закрытие сборки
|
<i class="bi bi-check2-square me-2"></i>Закрытие сборки
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}">
|
||||||
|
<i class="bi bi-box-seam me-1"></i>Комплектация
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="В разработке">
|
||||||
|
<i class="bi bi-basket3 me-1"></i>Доп. расходы
|
||||||
|
</button>
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|||||||
@@ -6,10 +6,15 @@
|
|||||||
<div class="card shadow border-secondary">
|
<div class="card shadow border-secondary">
|
||||||
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
|
<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-list-task me-2"></i>Реестр заданий</h3>
|
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
|
||||||
{% if user_role in 'admin,technologist,master' %}
|
{% if user_role in 'admin,technologist,master,operator' %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_workitems_print' %}?{{ request.GET.urlencode }}">
|
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_workitems_print' %}?{{ request.GET.urlencode }}">
|
||||||
<i class="bi bi-printer me-1"></i>Печать
|
<i class="bi bi-printer me-1"></i>Печать
|
||||||
</a>
|
</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'registry_workitems_download' %}?{{ request.GET.urlencode }}">
|
||||||
|
<i class="bi bi-download me-1"></i>Скачать
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card shadow border-secondary">
|
||||||
|
<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-download me-2"></i>Скачать сменное задание</h3>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'registry' %}?{{ request.GET.urlencode }}">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>В реестр
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
В выгрузку попадают <strong>задания из текущего фильтра реестра</strong> (как на экране).
|
||||||
|
Дополнительно: только <strong>задания «В работе»</strong> и только <strong>сделки «В работе»</strong>.
|
||||||
|
HTML формируется тем же шаблоном, что и кнопка <strong>«Печать»</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="?{{ request.GET.urlencode }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" value="1" id="translit" name="translit">
|
||||||
|
<label class="form-check-label" for="translit">
|
||||||
|
Транслитерировать имена файлов и папок (для совместимости)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-archive me-1"></i>Подготовить и скачать архив
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -31,6 +31,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if export_bar_text %}
|
||||||
|
<div class="container-fluid my-3">
|
||||||
|
<div class="alert alert-info py-2 mb-0">
|
||||||
|
{{ export_bar_text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
<div class="container-fluid page my-3">
|
<div class="container-fluid page my-3">
|
||||||
<div class="print-header">
|
<div class="print-header">
|
||||||
@@ -64,7 +72,12 @@
|
|||||||
<td class="center">{{ wi.date|date:"d.m.y" }}</td>
|
<td class="center">{{ wi.date|date:"d.m.y" }}</td>
|
||||||
<td class="center">{{ wi.deal.number|default:"-" }}</td>
|
<td class="center">{{ wi.deal.number|default:"-" }}</td>
|
||||||
<td>{{ wi.operation.name|default:wi.stage|default:"—" }}</td>
|
<td>{{ wi.operation.name|default:wi.stage|default:"—" }}</td>
|
||||||
<td>{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}</td>
|
<td>
|
||||||
|
{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}
|
||||||
|
{% if wi.entity.blank_length_mm and not wi.entity.dxf_file and not wi.entity.pdf_main %}
|
||||||
|
<div class="small text-muted">Длина: {{ wi.entity.blank_length_mm|floatformat:0 }} мм</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if wi.entity.planned_material %}
|
{% if wi.entity.planned_material %}
|
||||||
{{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }}
|
{{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }}
|
||||||
|
|||||||
@@ -28,12 +28,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user_role in 'admin,technologist,master,clerk' %}
|
{% if user_role in 'admin,technologist,master,clerk' %}
|
||||||
{% if workitem.entity.entity_type == 'product' or workitem.entity.entity_type == 'assembly' %}
|
{% if is_first_operation and workitem.entity.entity_type == 'product' or is_first_operation and workitem.entity.entity_type == 'assembly' %}
|
||||||
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}">
|
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}">
|
||||||
<i class="bi bi-box-seam me-1"></i>Комплектация
|
<i class="bi bi-box-seam me-1"></i>Комплектация
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user_role in 'admin,technologist,master,operator,prod_head' %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="В разработке">
|
||||||
|
<i class="bi bi-basket3 me-1"></i>Доп. расходы
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,13 @@
|
|||||||
<h3 class="text-accent mb-0">
|
<h3 class="text-accent mb-0">
|
||||||
<i class="bi bi-check2-square me-2"></i>Закрытие операции
|
<i class="bi bi-check2-square me-2"></i>Закрытие операции
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="В разработке">
|
||||||
|
<i class="bi bi-basket3 me-1"></i>Доп. расходы
|
||||||
|
</button>
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -25,6 +30,65 @@
|
|||||||
Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения.
|
Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if move_hint and work_location %}
|
||||||
|
<div class="alert alert-warning border-warning">
|
||||||
|
На складе участка <strong>{{ work_location.name }}</strong> не хватает изделий для закрытия операции.
|
||||||
|
Нужно: <strong>{{ move_hint.needed }}</strong> · доступно: <strong>{{ move_hint.available }}</strong> · не хватает: <strong>{{ move_hint.missing }}</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-outline-accent" data-bs-toggle="modal" data-bs-target="#moveToWorkLocationModal" {% if not move_candidates %}disabled{% endif %}>
|
||||||
|
<i class="bi bi-arrow-left-right me-1"></i>Переместить на участок
|
||||||
|
</button>
|
||||||
|
{% if not move_candidates %}
|
||||||
|
<div class="small text-muted align-self-center">Нет доступных позиций для перемещения</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="moveToWorkLocationModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<form method="post" action="{% url 'warehouse_transfer' %}" class="modal-content border-secondary">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<input type="hidden" name="to_location_id" value="{{ work_location.id }}">
|
||||||
|
|
||||||
|
<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 class="fw-bold">{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}</div>
|
||||||
|
<div class="small text-muted">Склад участка: {{ work_location.name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Откуда (позиция склада)</label>
|
||||||
|
<select class="form-select" name="stock_item_id" required>
|
||||||
|
{% for c in move_candidates %}
|
||||||
|
<option value="{{ c.id }}">{{ c.location.name }} · доступно {{ c.quantity|floatformat:"-g" }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Количество</label>
|
||||||
|
<input class="form-control" name="quantity" inputmode="decimal" value="{{ move_hint.missing }}" required>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="row align-items-end g-2">
|
<div class="row align-items-end g-2">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from .views import (
|
|||||||
WorkItemKittingPrintView,
|
WorkItemKittingPrintView,
|
||||||
AssemblyClosingView,
|
AssemblyClosingView,
|
||||||
WorkItemRegistryPrintView,
|
WorkItemRegistryPrintView,
|
||||||
|
WorkItemRegistryDownloadView,
|
||||||
RegistryView,
|
RegistryView,
|
||||||
SteelGradesCatalogView,
|
SteelGradesCatalogView,
|
||||||
SteelGradeUpsertView,
|
SteelGradeUpsertView,
|
||||||
@@ -111,6 +112,7 @@ urlpatterns = [
|
|||||||
# Печать сменного листа
|
# Печать сменного листа
|
||||||
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
|
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
|
||||||
path('registry/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'),
|
path('registry/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'),
|
||||||
|
path('registry/workitems/download/', WorkItemRegistryDownloadView.as_view(), name='registry_workitems_download'),
|
||||||
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'),
|
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'),
|
||||||
path('workitem/<int:pk>/', WorkItemDetailView.as_view(), name='workitem_detail'),
|
path('workitem/<int:pk>/', WorkItemDetailView.as_view(), name='workitem_detail'),
|
||||||
path('workitem/<int:pk>/op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'),
|
path('workitem/<int:pk>/op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from django.db import close_old_connections, transaction
|
|||||||
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Max, Sum, Value, When
|
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Max, Sum, Value, When
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.http import JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.views import View
|
from django.views import View
|
||||||
@@ -30,6 +30,86 @@ from shiftflow.authz import get_user_group_roles, get_user_roles, primary_role,
|
|||||||
logger = logging.getLogger('mes')
|
logger = logging.getLogger('mes')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_registry_workitems_queryset(request, *, role: str, profile):
|
||||||
|
"""Возвращает queryset WorkItem, соответствующий фильтрам страницы реестра.
|
||||||
|
|
||||||
|
Это ровно та же выборка, которая используется для таблицы WorkItem на странице
|
||||||
|
[registry] (см. RegistryView.get_context_data). Нужна для печати/выгрузок,
|
||||||
|
чтобы брать задания «как на экране».
|
||||||
|
"""
|
||||||
|
qs = WorkItem.objects.select_related(
|
||||||
|
'deal',
|
||||||
|
'deal__company',
|
||||||
|
'entity',
|
||||||
|
'entity__planned_material',
|
||||||
|
'operation',
|
||||||
|
'machine',
|
||||||
|
'workshop',
|
||||||
|
)
|
||||||
|
|
||||||
|
q = (request.GET.get('q') or '').strip()
|
||||||
|
if q:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(deal__number__icontains=q)
|
||||||
|
| Q(entity__name__icontains=q)
|
||||||
|
| Q(entity__drawing_number__icontains=q)
|
||||||
|
| Q(entity__planned_material__name__icontains=q)
|
||||||
|
| Q(entity__planned_material__full_name__icontains=q)
|
||||||
|
)
|
||||||
|
|
||||||
|
m_ids = [int(i) for i in request.GET.getlist('m_ids') if str(i).isdigit()]
|
||||||
|
if m_ids:
|
||||||
|
qs = qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True))
|
||||||
|
|
||||||
|
filtered = request.GET.get('filtered')
|
||||||
|
reset = request.GET.get('reset')
|
||||||
|
is_default = (not filtered) or bool(reset)
|
||||||
|
|
||||||
|
if is_default:
|
||||||
|
today = timezone.localdate()
|
||||||
|
week_ago = today - timezone.timedelta(days=7)
|
||||||
|
qs = qs.filter(date__gte=week_ago, date__lte=today)
|
||||||
|
else:
|
||||||
|
start_date = (request.GET.get('start_date') or '').strip()
|
||||||
|
end_date = (request.GET.get('end_date') or '').strip()
|
||||||
|
if start_date:
|
||||||
|
qs = qs.filter(date__gte=start_date)
|
||||||
|
if end_date:
|
||||||
|
qs = qs.filter(date__lte=end_date)
|
||||||
|
|
||||||
|
statuses = request.GET.getlist('statuses')
|
||||||
|
if is_default:
|
||||||
|
qs = qs.filter(status__in=['planned'])
|
||||||
|
else:
|
||||||
|
if not statuses:
|
||||||
|
qs = qs.none()
|
||||||
|
else:
|
||||||
|
expanded = []
|
||||||
|
for s in statuses:
|
||||||
|
if s == 'work':
|
||||||
|
expanded += ['planned']
|
||||||
|
elif s == 'leftover':
|
||||||
|
expanded.append('leftover')
|
||||||
|
elif s == 'closed':
|
||||||
|
expanded.append('done')
|
||||||
|
if expanded:
|
||||||
|
qs = qs.filter(status__in=expanded)
|
||||||
|
|
||||||
|
if role == 'operator':
|
||||||
|
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
|
||||||
|
if allowed_ws:
|
||||||
|
qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
|
||||||
|
else:
|
||||||
|
user_machines = profile.machines.all() if profile else Machine.objects.none()
|
||||||
|
qs = qs.filter(machine__in=user_machines)
|
||||||
|
elif role == 'master':
|
||||||
|
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
|
||||||
|
if allowed_ws:
|
||||||
|
qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_default_delivery_batch(deal_id: int) -> None:
|
def _reconcile_default_delivery_batch(deal_id: int) -> None:
|
||||||
deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity'))
|
deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity'))
|
||||||
if not deal_items:
|
if not deal_items:
|
||||||
@@ -111,6 +191,8 @@ from warehouse.models import Location, Material, MaterialCategory, SteelGrade, S
|
|||||||
from warehouse.services.transfers import receive_transfer
|
from warehouse.services.transfers import receive_transfer
|
||||||
|
|
||||||
from shiftflow.services.closing import apply_closing, apply_closing_workitems
|
from shiftflow.services.closing import apply_closing, apply_closing_workitems
|
||||||
|
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
|
||||||
|
from shiftflow.services.workitem_registry_export import build_workitem_registry_export_zip
|
||||||
from shiftflow.services.bom_explosion import (
|
from shiftflow.services.bom_explosion import (
|
||||||
explode_deal,
|
explode_deal,
|
||||||
explode_roots_additive,
|
explode_roots_additive,
|
||||||
@@ -522,65 +604,7 @@ class RegistryView(LoginRequiredMixin, ListView):
|
|||||||
it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning'
|
it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning'
|
||||||
context['items'] = items
|
context['items'] = items
|
||||||
|
|
||||||
work_qs = WorkItem.objects.select_related('deal', 'deal__company', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop')
|
work_qs = _build_registry_workitems_queryset(self.request, role=str(role or ''), profile=profile)
|
||||||
|
|
||||||
q = (self.request.GET.get('q') or '').strip()
|
|
||||||
if q:
|
|
||||||
work_qs = work_qs.filter(
|
|
||||||
Q(deal__number__icontains=q)
|
|
||||||
| Q(entity__name__icontains=q)
|
|
||||||
| Q(entity__drawing_number__icontains=q)
|
|
||||||
| Q(entity__planned_material__name__icontains=q)
|
|
||||||
| Q(entity__planned_material__full_name__icontains=q)
|
|
||||||
)
|
|
||||||
|
|
||||||
m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()]
|
|
||||||
if m_ids:
|
|
||||||
work_qs = work_qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True))
|
|
||||||
|
|
||||||
filtered = self.request.GET.get('filtered')
|
|
||||||
reset = self.request.GET.get('reset')
|
|
||||||
is_default = (not filtered) or bool(reset)
|
|
||||||
|
|
||||||
if is_default:
|
|
||||||
today = timezone.localdate()
|
|
||||||
week_ago = today - timezone.timedelta(days=7)
|
|
||||||
work_qs = work_qs.filter(date__gte=week_ago, date__lte=today)
|
|
||||||
else:
|
|
||||||
if context.get('start_date'):
|
|
||||||
work_qs = work_qs.filter(date__gte=context['start_date'])
|
|
||||||
if context.get('end_date'):
|
|
||||||
work_qs = work_qs.filter(date__lte=context['end_date'])
|
|
||||||
|
|
||||||
statuses = self.request.GET.getlist('statuses')
|
|
||||||
if is_default:
|
|
||||||
work_qs = work_qs.filter(status__in=['planned'])
|
|
||||||
else:
|
|
||||||
if not statuses:
|
|
||||||
work_qs = work_qs.none()
|
|
||||||
else:
|
|
||||||
expanded = []
|
|
||||||
for s in statuses:
|
|
||||||
if s == 'work':
|
|
||||||
expanded += ['planned']
|
|
||||||
elif s == 'leftover':
|
|
||||||
expanded.append('leftover')
|
|
||||||
elif s == 'closed':
|
|
||||||
expanded.append('done')
|
|
||||||
if expanded:
|
|
||||||
work_qs = work_qs.filter(status__in=expanded)
|
|
||||||
|
|
||||||
if role == 'operator':
|
|
||||||
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
|
|
||||||
if allowed_ws:
|
|
||||||
work_qs = work_qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
|
|
||||||
else:
|
|
||||||
user_machines = profile.machines.all() if profile else Machine.objects.none()
|
|
||||||
work_qs = work_qs.filter(machine__in=user_machines)
|
|
||||||
elif role == 'master':
|
|
||||||
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
|
|
||||||
if allowed_ws:
|
|
||||||
work_qs = work_qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
|
|
||||||
|
|
||||||
workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000])
|
workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000])
|
||||||
for wi in workitems:
|
for wi in workitems:
|
||||||
@@ -1282,9 +1306,65 @@ class WorkItemRegistryPrintView(LoginRequiredMixin, TemplateView):
|
|||||||
g['items'].append(wi)
|
g['items'].append(wi)
|
||||||
|
|
||||||
ctx['groups'] = list(groups.values())
|
ctx['groups'] = list(groups.values())
|
||||||
|
ctx['printed_at'] = timezone.now()
|
||||||
|
|
||||||
|
print_date_raw = end_date or start_date
|
||||||
|
print_date = None
|
||||||
|
if isinstance(print_date_raw, str) and print_date_raw:
|
||||||
|
try:
|
||||||
|
print_date = datetime.strptime(print_date_raw, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
print_date = None
|
||||||
|
ctx['print_date'] = print_date or timezone.localdate()
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItemRegistryDownloadView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = 'shiftflow/registry_workitems_download.html'
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
roles = get_user_group_roles(request.user)
|
||||||
|
if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']):
|
||||||
|
return redirect('index')
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
roles = get_user_group_roles(self.request.user)
|
||||||
|
ctx['user_roles'] = sorted(roles)
|
||||||
|
ctx['user_role'] = primary_role(roles)
|
||||||
|
ctx['only_work_deals'] = True
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
roles = get_user_group_roles(request.user)
|
||||||
|
role = primary_role(roles)
|
||||||
|
profile = getattr(request.user, 'profile', None)
|
||||||
|
|
||||||
|
translit = (request.POST.get('translit') or '').strip() in ['1', 'true', 'on', 'yes']
|
||||||
|
|
||||||
|
try:
|
||||||
|
qs = _build_registry_workitems_queryset(request, role=str(role or ''), profile=profile)
|
||||||
|
qs = qs.filter(deal__status='work', status='planned')
|
||||||
|
workitem_ids = list(qs.values_list('id', flat=True))
|
||||||
|
|
||||||
|
zip_bytes, filename = build_workitem_registry_export_zip(
|
||||||
|
request=request,
|
||||||
|
workitem_ids=workitem_ids,
|
||||||
|
translit=bool(translit),
|
||||||
|
only_work_deals=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('workitem_registry_download:error user_id=%s', getattr(request.user, 'id', None))
|
||||||
|
messages.error(request, f'Ошибка формирования архива: {type(e).__name__}: {e}')
|
||||||
|
url = f"{reverse_lazy('registry_workitems_download')}?{request.GET.urlencode()}" if request.GET else str(reverse_lazy('registry_workitems_download'))
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
|
resp = HttpResponse(zip_bytes, content_type='application/zip')
|
||||||
|
resp['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
class WorkItemEntityListView(LoginRequiredMixin, TemplateView):
|
class WorkItemEntityListView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = 'shiftflow/workitem_entity_list.html'
|
template_name = 'shiftflow/workitem_entity_list.html'
|
||||||
|
|
||||||
@@ -1360,6 +1440,9 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
|
|||||||
if wi.entity and wi.entity.entity_type in ['product', 'assembly']:
|
if wi.entity and wi.entity.entity_type in ['product', 'assembly']:
|
||||||
return redirect('assembly_closing', pk=wi.id)
|
return redirect('assembly_closing', pk=wi.id)
|
||||||
if wi.entity and wi.entity.entity_type == 'part':
|
if wi.entity and wi.entity.entity_type == 'part':
|
||||||
|
if getattr(wi.entity, 'planned_material_id', None) and not wi.machine_id:
|
||||||
|
messages.error(request, 'Для закрытия операции нужно выбрать пост/станок в сменном задании.')
|
||||||
|
return redirect('workitem_detail', pk=wi.id)
|
||||||
if wi.machine_id and getattr(wi.entity, 'planned_material_id', None):
|
if wi.machine_id and getattr(wi.entity, 'planned_material_id', None):
|
||||||
return redirect(f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}")
|
return redirect(f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}")
|
||||||
|
|
||||||
@@ -1377,6 +1460,51 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
|
|||||||
ctx['workitem'] = wi
|
ctx['workitem'] = wi
|
||||||
ctx['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0))
|
ctx['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0))
|
||||||
ctx['is_first_operation'] = bool(self.is_first_operation)
|
ctx['is_first_operation'] = bool(self.is_first_operation)
|
||||||
|
|
||||||
|
work_location = get_work_location_for_workitem(wi)
|
||||||
|
ctx['work_location'] = work_location
|
||||||
|
|
||||||
|
hint = (self.request.session.get('op_closing_move_hint') or None)
|
||||||
|
if hint and int(hint.get('workitem_id') or 0) == int(wi.id):
|
||||||
|
try:
|
||||||
|
self.request.session.pop('op_closing_move_hint', None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
needed = int(hint.get('qty') or 0)
|
||||||
|
available = int(hint.get('available') or 0)
|
||||||
|
missing = max(0, needed - available)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
if work_location and missing > 0:
|
||||||
|
qs = (
|
||||||
|
StockItem.objects.select_related('location')
|
||||||
|
.filter(is_archived=False, quantity__gt=0)
|
||||||
|
.filter(entity_id=int(wi.entity_id))
|
||||||
|
.exclude(location_id=int(work_location.id))
|
||||||
|
.filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True))
|
||||||
|
.annotate(
|
||||||
|
prio=Case(
|
||||||
|
When(deal_id=wi.deal_id, then=Value(0)),
|
||||||
|
default=Value(1),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by('prio', 'created_at', 'id')
|
||||||
|
)
|
||||||
|
for si in list(qs[:30]):
|
||||||
|
candidates.append({'id': int(si.id), 'location': si.location, 'quantity': float(si.quantity)})
|
||||||
|
|
||||||
|
ctx['move_hint'] = {
|
||||||
|
'needed': int(needed),
|
||||||
|
'available': int(available),
|
||||||
|
'missing': int(missing),
|
||||||
|
}
|
||||||
|
ctx['move_candidates'] = candidates
|
||||||
|
else:
|
||||||
|
ctx['move_hint'] = None
|
||||||
|
ctx['move_candidates'] = []
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
@@ -1409,7 +1537,8 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
|
|||||||
)['s']
|
)['s']
|
||||||
available_i = int(available or 0)
|
available_i = int(available or 0)
|
||||||
if qty > available_i:
|
if qty > available_i:
|
||||||
messages.error(request, f'Нельзя закрыть операцию: на складе участка «{work_location.name}» доступно {available_i} шт. Сначала перемести изделие на участок.')
|
request.session['op_closing_move_hint'] = {'workitem_id': int(wi.id), 'qty': int(qty), 'available': int(available_i)}
|
||||||
|
messages.error(request, f'На складе участка «{work_location.name}» нет нужного количества: доступно {available_i} шт. Перемести изделие на участок и повтори закрытие.')
|
||||||
return redirect('workitem_op_closing', pk=wi.id)
|
return redirect('workitem_op_closing', pk=wi.id)
|
||||||
|
|
||||||
plan_total = int(wi.quantity_plan or 0)
|
plan_total = int(wi.quantity_plan or 0)
|
||||||
@@ -1468,8 +1597,11 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
|
|||||||
if int(total_done or 0) >= int(target_qty):
|
if int(total_done or 0) >= int(target_qty):
|
||||||
progress.current_seq = cur + 1
|
progress.current_seq = cur + 1
|
||||||
progress.save(update_fields=['current_seq'])
|
progress.save(update_fields=['current_seq'])
|
||||||
|
advance_progress_and_generate_next_workitem(workitem_id=int(wi.id))
|
||||||
|
|
||||||
messages.success(request, f'Закрыто: {qty} шт.')
|
messages.success(request, f'Закрыто: {qty} шт.')
|
||||||
|
if (wi.status or '') == 'done':
|
||||||
|
return redirect('registry')
|
||||||
return redirect('workitem_detail', pk=wi.id)
|
return redirect('workitem_detail', pk=wi.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -5244,7 +5376,7 @@ class AssemblyClosingView(LoginRequiredMixin, TemplateView):
|
|||||||
try:
|
try:
|
||||||
apply_assembly_closing(self.workitem.id, qty, request.user.id)
|
apply_assembly_closing(self.workitem.id, qty, request.user.id)
|
||||||
messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.')
|
messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.')
|
||||||
return redirect('workitem_detail', pk=self.workitem.id)
|
return redirect('registry')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception('assembly_closing: error')
|
logger.exception('assembly_closing: error')
|
||||||
messages.error(request, f'Ошибка закрытия: {e}')
|
messages.error(request, f'Ошибка закрытия: {e}')
|
||||||
|
|||||||
Reference in New Issue
Block a user