Доработал закрытие, техоперации автоматом переключаются, добавил выгрузку сменных заданий
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]
|
||||
### Added
|
||||
- Журнал отгрузки: список документов перемещения на «Склад отгруженных позиций».
|
||||
- Реестр заданий: выгрузка сменного задания в архив ZIP (HTML как в «Печать», TXT и manifest) с прикреплением файлов КД по материалам.
|
||||
|
||||
### Changed
|
||||
- Версия приложения: 0.9.3.
|
||||
- Отгрузка: можно добавлять несколько сделок в одну сессию отгрузки, выбирать позиции и подтверждать общий список.
|
||||
- Печать реестра WorkItem: добавлен вывод длины заготовки для позиций без чертежей (типовой случай ленточнопилы); добавлена опциональная плашка «Сформировано …» для экспортируемого HTML.
|
||||
- Сменные задания: кнопка «Комплектация» показывается только на первой операции сборки/изделия, добавлена кнопка «Доп. расходы» (в разработке).
|
||||
- Журнал отгрузки: добавлены фильтр по периоду (по умолчанию 2 недели) и поиск по сделкам (номер/описание/заказчик), убран столбец «Куда».
|
||||
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
|
||||
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
|
||||
@@ -24,7 +28,14 @@
|
||||
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса или материала: показывается модалка и отдельная страница со списком проблемных позиций.
|
||||
|
||||
### Fixed
|
||||
- Выгрузка сменного задания: имена файлов КД формируются с суффиксом nXX как количеством деталей к изготовлению (а не порядковым номером файла).
|
||||
- Закрытие первой операции детали: запрещено закрытие без выбранного поста/станка (чтобы не было закрытия без списания сырья).
|
||||
- Закрытие сборки/изделия: после закрытия выполняется возврат в реестр.
|
||||
- Закрытие последующих операций (например, покраски): после полного закрытия выполняется возврат в реестр.
|
||||
- Закрытие последующих операций: при нехватке изделий на складе участка показывается окно перемещения на участок.
|
||||
- Починено закрытие сборок/изделий на странице «Закрыть сборку»: выбор поста доступен и сохраняется, списание/выпуск выполняются.
|
||||
- Закрытие сборки/изделия: при закрытии первой операции корректно продвигается текущая операция техпроцесса (переход на следующую, например на покраску).
|
||||
- Закрытие операций: при полном выполнении операции автоматически продвигается маршрут и создаётся следующее сменное задание (например, после резки — гибка, после сварки — покраска).
|
||||
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
|
||||
- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ if os.path.exists(env_file):
|
||||
|
||||
# читаем переменную окружения
|
||||
ENV_TYPE = os.getenv('ENV_TYPE', 'local')
|
||||
APP_VERSION = '0.8.9'
|
||||
APP_VERSION = '0.9.3'
|
||||
|
||||
# Настройки безопасности
|
||||
# DEBUG будет True везде, кроме сервера
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import logging
|
||||
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 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.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:
|
||||
@@ -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:
|
||||
workitem.status = 'done'
|
||||
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(
|
||||
'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,
|
||||
)
|
||||
from shiftflow.services.sessions import close_cutting_session
|
||||
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
|
||||
|
||||
logger = logging.getLogger('mes')
|
||||
|
||||
@@ -208,6 +209,7 @@ def apply_closing_workitems(
|
||||
else:
|
||||
wi.status = 'planned'
|
||||
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():
|
||||
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,7 +8,15 @@
|
||||
<h3 class="text-accent mb-0">
|
||||
<i class="bi bi-check2-square me-2"></i>Закрытие сборки
|
||||
</h3>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
<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-list-task me-2"></i>Реестр заданий</h3>
|
||||
{% if user_role in 'admin,technologist,master' %}
|
||||
<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>Печать
|
||||
</a>
|
||||
{% 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 }}">
|
||||
<i class="bi bi-printer me-1"></i>Печать
|
||||
</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 %}
|
||||
</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>
|
||||
|
||||
{% 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 %}
|
||||
<div class="container-fluid page my-3">
|
||||
<div class="print-header">
|
||||
@@ -64,7 +72,12 @@
|
||||
<td class="center">{{ wi.date|date:"d.m.y" }}</td>
|
||||
<td class="center">{{ wi.deal.number|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>
|
||||
{% if wi.entity.planned_material %}
|
||||
{{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }}
|
||||
|
||||
@@ -28,12 +28,18 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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 }}">
|
||||
<i class="bi bi-box-seam me-1"></i>Комплектация
|
||||
</a>
|
||||
{% 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>
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
<h3 class="text-accent mb-0">
|
||||
<i class="bi bi-check2-square me-2"></i>Закрытие операции
|
||||
</h3>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'workitem_detail' workitem.id %}">Назад к заданию</a>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
@@ -25,6 +30,65 @@
|
||||
Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения.
|
||||
</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">
|
||||
{% csrf_token %}
|
||||
<div class="row align-items-end g-2">
|
||||
|
||||
@@ -41,6 +41,7 @@ from .views import (
|
||||
WorkItemKittingPrintView,
|
||||
AssemblyClosingView,
|
||||
WorkItemRegistryPrintView,
|
||||
WorkItemRegistryDownloadView,
|
||||
RegistryView,
|
||||
SteelGradesCatalogView,
|
||||
SteelGradeUpsertView,
|
||||
@@ -111,6 +112,7 @@ urlpatterns = [
|
||||
# Печать сменного листа
|
||||
path('registry/print/', RegistryPrintView.as_view(), name='registry_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('workitem/<int:pk>/', WorkItemDetailView.as_view(), name='workitem_detail'),
|
||||
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 Q
|
||||
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.urls import reverse_lazy
|
||||
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')
|
||||
|
||||
|
||||
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:
|
||||
deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity'))
|
||||
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 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 (
|
||||
explode_deal,
|
||||
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'
|
||||
context['items'] = items
|
||||
|
||||
work_qs = WorkItem.objects.select_related('deal', 'deal__company', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop')
|
||||
|
||||
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))
|
||||
work_qs = _build_registry_workitems_queryset(self.request, role=str(role or ''), profile=profile)
|
||||
|
||||
workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000])
|
||||
for wi in workitems:
|
||||
@@ -1282,9 +1306,65 @@ class WorkItemRegistryPrintView(LoginRequiredMixin, TemplateView):
|
||||
g['items'].append(wi)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
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']:
|
||||
return redirect('assembly_closing', pk=wi.id)
|
||||
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):
|
||||
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['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0))
|
||||
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
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@@ -1409,7 +1537,8 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
|
||||
)['s']
|
||||
available_i = int(available or 0)
|
||||
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)
|
||||
|
||||
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):
|
||||
progress.current_seq = cur + 1
|
||||
progress.save(update_fields=['current_seq'])
|
||||
advance_progress_and_generate_next_workitem(workitem_id=int(wi.id))
|
||||
|
||||
messages.success(request, f'Закрыто: {qty} шт.')
|
||||
if (wi.status or '') == 'done':
|
||||
return redirect('registry')
|
||||
return redirect('workitem_detail', pk=wi.id)
|
||||
|
||||
|
||||
@@ -5244,7 +5376,7 @@ class AssemblyClosingView(LoginRequiredMixin, TemplateView):
|
||||
try:
|
||||
apply_assembly_closing(self.workitem.id, qty, request.user.id)
|
||||
messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.')
|
||||
return redirect('workitem_detail', pk=self.workitem.id)
|
||||
return redirect('registry')
|
||||
except Exception as e:
|
||||
logger.exception('assembly_closing: error')
|
||||
messages.error(request, f'Ошибка закрытия: {e}')
|
||||
|
||||
Reference in New Issue
Block a user