diff --git a/CHANGELOG.md b/CHANGELOG.md index ecbfe41..aa84402 100644 --- a/CHANGELOG.md +++ b/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), чтобы компоненты не попадали в «без техпроцесса». - Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий. diff --git a/core/settings.py b/core/settings.py index 739046b..f5fd69d 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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 везде, кроме сервера diff --git a/shiftflow/services/assembly_closing.py b/shiftflow/services/assembly_closing.py index 28bd6c5..422c6d2 100644 --- a/shiftflow/services/assembly_closing.py +++ b/shiftflow/services/assembly_closing.py @@ -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', diff --git a/shiftflow/services/closing.py b/shiftflow/services/closing.py index 7274b1c..cf30374 100644 --- a/shiftflow/services/closing.py +++ b/shiftflow/services/closing.py @@ -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: diff --git a/shiftflow/services/route_flow.py b/shiftflow/services/route_flow.py new file mode 100644 index 0000000..7899e64 --- /dev/null +++ b/shiftflow/services/route_flow.py @@ -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) \ No newline at end of file diff --git a/shiftflow/services/workitem_registry_export.py b/shiftflow/services/workitem_registry_export.py new file mode 100644 index 0000000..ae256a2 --- /dev/null +++ b/shiftflow/services/workitem_registry_export.py @@ -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 \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/assembly_closing.html b/shiftflow/templates/shiftflow/assembly_closing.html index b14e84e..91ccde0 100644 --- a/shiftflow/templates/shiftflow/assembly_closing.html +++ b/shiftflow/templates/shiftflow/assembly_closing.html @@ -8,7 +8,15 @@

Закрытие сборки

- Назад к заданию +
+ + Комплектация + + + Назад к заданию +
diff --git a/shiftflow/templates/shiftflow/registry.html b/shiftflow/templates/shiftflow/registry.html index 97cfebd..bf2ce96 100644 --- a/shiftflow/templates/shiftflow/registry.html +++ b/shiftflow/templates/shiftflow/registry.html @@ -6,10 +6,15 @@

Реестр заданий

- {% if user_role in 'admin,technologist,master' %} - - Печать - + {% if user_role in 'admin,technologist,master,operator' %} + {% endif %}
diff --git a/shiftflow/templates/shiftflow/registry_workitems_download.html b/shiftflow/templates/shiftflow/registry_workitems_download.html new file mode 100644 index 0000000..010dab7 --- /dev/null +++ b/shiftflow/templates/shiftflow/registry_workitems_download.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Скачать сменное задание

+ + В реестр + +
+ +
+
+ В выгрузку попадают задания из текущего фильтра реестра (как на экране). + Дополнительно: только задания «В работе» и только сделки «В работе». + HTML формируется тем же шаблоном, что и кнопка «Печать». +
+ +
+ {% csrf_token %} + +
+ + +
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/shiftflow/templates/shiftflow/registry_workitems_print.html b/shiftflow/templates/shiftflow/registry_workitems_print.html index f60e0fe..842cff1 100644 --- a/shiftflow/templates/shiftflow/registry_workitems_print.html +++ b/shiftflow/templates/shiftflow/registry_workitems_print.html @@ -31,6 +31,14 @@
+ {% if export_bar_text %} +
+
+ {{ export_bar_text }} +
+
+ {% endif %} + {% for g in groups %}
diff --git a/shiftflow/templates/shiftflow/workitem_op_closing.html b/shiftflow/templates/shiftflow/workitem_op_closing.html index 2d43015..638fd74 100644 --- a/shiftflow/templates/shiftflow/workitem_op_closing.html +++ b/shiftflow/templates/shiftflow/workitem_op_closing.html @@ -8,7 +8,12 @@

Закрытие операции

- Назад к заданию +
+ + Назад к заданию +
@@ -25,6 +30,65 @@ Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения.
+ {% if move_hint and work_location %} +
+ На складе участка {{ work_location.name }} не хватает изделий для закрытия операции. + Нужно: {{ move_hint.needed }} · доступно: {{ move_hint.available }} · не хватает: {{ move_hint.missing }}. +
+ +
+ + {% if not move_candidates %} +
Нет доступных позиций для перемещения
+ {% endif %} +
+ + + + {% endif %} +
{% csrf_token %}
diff --git a/shiftflow/urls.py b/shiftflow/urls.py index 0ef4158..2177c34 100644 --- a/shiftflow/urls.py +++ b/shiftflow/urls.py @@ -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//', ItemUpdateView.as_view(), name='item_detail'), path('workitem//', WorkItemDetailView.as_view(), name='workitem_detail'), path('workitem//op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'), diff --git a/shiftflow/views.py b/shiftflow/views.py index 9bae60b..3c07423 100644 --- a/shiftflow/views.py +++ b/shiftflow/views.py @@ -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}')