Доработал закрытие, техоперации автоматом переключаются, добавил выгрузку сменных заданий
All checks were successful
Deploy MES Core / deploy (push) Successful in 14s

This commit is contained in:
2026-04-25 11:58:11 +03:00
parent 6fd01c9a6e
commit 909ba05b5d
14 changed files with 998 additions and 74 deletions

View File

@@ -10,9 +10,13 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- Журнал отгрузки: список документов перемещения на «Склад отгруженных позиций». - Журнал отгрузки: список документов перемещения на «Склад отгруженных позиций».
- Реестр заданий: выгрузка сменного задания в архив ZIP (HTML как в «Печать», TXT и manifest) с прикреплением файлов КД по материалам.
### Changed ### Changed
- Версия приложения: 0.9.3.
- Отгрузка: можно добавлять несколько сделок в одну сессию отгрузки, выбирать позиции и подтверждать общий список. - Отгрузка: можно добавлять несколько сделок в одну сессию отгрузки, выбирать позиции и подтверждать общий список.
- Печать реестра WorkItem: добавлен вывод длины заготовки для позиций без чертежей (типовой случай ленточнопилы); добавлена опциональная плашка «Сформировано …» для экспортируемого HTML.
- Сменные задания: кнопка «Комплектация» показывается только на первой операции сборки/изделия, добавлена кнопка «Доп. расходы» (в разработке).
- Журнал отгрузки: добавлены фильтр по периоду (по умолчанию 2 недели) и поиск по сделкам (номер/описание/заказчик), убран столбец «Куда». - Журнал отгрузки: добавлены фильтр по периоду (по умолчанию 2 недели) и поиск по сделкам (номер/описание/заказчик), убран столбец «Куда».
- Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк. - Списание / Производство: в блоках «Списано» и «Остаток ДО» выводится масса материалов (по размерам и «Масса на ед. учёта»); если масса не задана — показывается прочерк.
- Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке. - Закрытие: деловой остаток (ДО) может наследовать сделку от списанного сырья (отключается чекбоксом) и доступен к отгрузке как сырьё по сделке.
@@ -24,7 +28,14 @@
- Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса или материала: показывается модалка и отдельная страница со списком проблемных позиций. - Улучшено сообщение о блокировке запуска «В производство» при отсутствии техпроцесса или материала: показывается модалка и отдельная страница со списком проблемных позиций.
### Fixed ### Fixed
- Выгрузка сменного задания: имена файлов КД формируются с суффиксом nXX как количеством деталей к изготовлению (а не порядковым номером файла).
- Закрытие первой операции детали: запрещено закрытие без выбранного поста/станка (чтобы не было закрытия без списания сырья).
- Закрытие сборки/изделия: после закрытия выполняется возврат в реестр.
- Закрытие последующих операций (например, покраски): после полного закрытия выполняется возврат в реестр.
- Закрытие последующих операций: при нехватке изделий на складе участка показывается окно перемещения на участок.
- Починено закрытие сборок/изделий на странице «Закрыть сборку»: выбор поста доступен и сохраняется, списание/выпуск выполняются. - Починено закрытие сборок/изделий на странице «Закрыть сборку»: выбор поста доступен и сохраняется, списание/выпуск выполняются.
- Закрытие сборки/изделия: при закрытии первой операции корректно продвигается текущая операция техпроцесса (переход на следующую, например на покраску).
- Закрытие операций: при полном выполнении операции автоматически продвигается маршрут и создаётся следующее сменное задание (например, после резки — гибка, после сварки — покраска).
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса». - Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий. - Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий.

View File

@@ -30,7 +30,7 @@ if os.path.exists(env_file):
# читаем переменную окружения # читаем переменную окружения
ENV_TYPE = os.getenv('ENV_TYPE', 'local') ENV_TYPE = os.getenv('ENV_TYPE', 'local')
APP_VERSION = '0.8.9' APP_VERSION = '0.9.3'
# Настройки безопасности # Настройки безопасности
# DEBUG будет True везде, кроме сервера # DEBUG будет True везде, кроме сервера

View File

@@ -1,12 +1,22 @@
import logging import logging
from django.db import transaction from django.db import transaction
from django.db.models import Q, Case, When, Value, IntegerField from django.db.models import Q, Case, When, Value, IntegerField, Sum
from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from warehouse.models import StockItem from warehouse.models import StockItem
from shiftflow.models import WorkItem, CuttingSession, ProductionReportConsumption, ProductionReportStockResult from shiftflow.models import (
DealEntityProgress,
DealItem,
ProductionTask,
WorkItem,
CuttingSession,
ProductionReportConsumption,
ProductionReportStockResult,
)
from shiftflow.services.bom_explosion import _build_bom_graph from shiftflow.services.bom_explosion import _build_bom_graph
from shiftflow.services.kitting import get_work_location_for_workitem from shiftflow.services.kitting import get_work_location_for_workitem
from manufacturing.models import EntityOperation from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
from manufacturing.models import EntityOperation, Operation
def get_first_operation_id(entity_id: int) -> int | None: def get_first_operation_id(entity_id: int) -> int | None:
@@ -189,6 +199,59 @@ def apply_assembly_closing(workitem_id: int, fact_qty: int, user_id: int) -> boo
if workitem.quantity_done >= workitem.quantity_plan: if workitem.quantity_done >= workitem.quantity_plan:
workitem.status = 'done' workitem.status = 'done'
workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status']) workitem.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
advance_progress_and_generate_next_workitem(workitem_id=int(workitem.id))
target_qty = None
if getattr(workitem, 'delivery_batch_id', None):
target_qty = ProductionTask.objects.filter(
deal_id=workitem.deal_id,
delivery_batch_id=workitem.delivery_batch_id,
entity_id=workitem.entity_id,
).values_list('quantity_ordered', flat=True).first()
else:
di = DealItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).first()
target_qty = int(di.quantity) if di else None
if target_qty is not None:
op_code = ''
if getattr(workitem, 'operation_id', None):
op_code = (Operation.objects.filter(pk=workitem.operation_id).values_list('code', flat=True).first() or '').strip()
if not op_code:
op_code = (workitem.stage or '').strip()
if op_code:
progress = (
DealEntityProgress.objects.select_for_update(of=('self',))
.filter(
deal_id=workitem.deal_id,
delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None),
entity_id=workitem.entity_id,
)
.first()
)
if not progress:
progress = DealEntityProgress.objects.create(
deal_id=workitem.deal_id,
delivery_batch_id=(int(workitem.delivery_batch_id) if getattr(workitem, 'delivery_batch_id', None) else None),
entity_id=workitem.entity_id,
current_seq=1,
)
cur = int(progress.current_seq or 1)
cur_eo = EntityOperation.objects.select_related('operation').filter(entity_id=workitem.entity_id, seq=cur).first()
if cur_eo and cur_eo.operation and (cur_eo.operation.code or '').strip() == op_code:
wi_qs = WorkItem.objects.filter(deal_id=workitem.deal_id, entity_id=workitem.entity_id).filter(
Q(operation__code=op_code) | Q(stage=op_code)
)
if getattr(workitem, 'delivery_batch_id', None):
wi_qs = wi_qs.filter(delivery_batch_id=workitem.delivery_batch_id)
else:
wi_qs = wi_qs.filter(delivery_batch_id__isnull=True)
total_done = wi_qs.aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
if int(total_done or 0) >= int(target_qty):
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
logger.info( logger.info(
'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s', 'assembly_closing:done workitem_id=%s qty=%s deal_id=%s location_id=%s user_id=%s report_id=%s',

View File

@@ -11,6 +11,7 @@ from shiftflow.models import (
ShiftItem, ShiftItem,
) )
from shiftflow.services.sessions import close_cutting_session from shiftflow.services.sessions import close_cutting_session
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
logger = logging.getLogger('mes') logger = logging.getLogger('mes')
@@ -208,6 +209,7 @@ def apply_closing_workitems(
else: else:
wi.status = 'planned' wi.status = 'planned'
wi.save(update_fields=['quantity_done', 'quantity_reported', 'status']) wi.save(update_fields=['quantity_done', 'quantity_reported', 'status'])
advance_progress_and_generate_next_workitem(workitem_id=int(wi.id))
for stock_item_id, qty in consumptions.items(): for stock_item_id, qty in consumptions.items():
if qty and float(qty) > 0: if qty and float(qty) > 0:

View 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)

View 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

View File

@@ -8,7 +8,15 @@
<h3 class="text-accent mb-0"> <h3 class="text-accent mb-0">
<i class="bi bi-check2-square me-2"></i>Закрытие сборки <i class="bi bi-check2-square me-2"></i>Закрытие сборки
</h3> </h3>
<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>
<div class="card-body p-4"> <div class="card-body p-4">

View File

@@ -6,10 +6,15 @@
<div class="card shadow border-secondary"> <div class="card shadow border-secondary">
<div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center"> <div class="card-header border-secondary py-3 d-flex justify-content-between align-items-center">
<h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3> <h3 class="text-accent mb-0"><i class="bi bi-list-task me-2"></i>Реестр заданий</h3>
{% if user_role in 'admin,technologist,master' %} {% if user_role in 'admin,technologist,master,operator' %}
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_workitems_print' %}?{{ request.GET.urlencode }}"> <div class="d-flex gap-2">
<i class="bi bi-printer me-1"></i>Печать <a class="btn btn-outline-secondary btn-sm" target="_blank" href="{% url 'registry_workitems_print' %}?{{ request.GET.urlencode }}">
</a> <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 %} {% endif %}
</div> </div>

View File

@@ -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 %}

View File

@@ -31,6 +31,14 @@
</div> </div>
</div> </div>
{% if export_bar_text %}
<div class="container-fluid my-3">
<div class="alert alert-info py-2 mb-0">
{{ export_bar_text }}
</div>
</div>
{% endif %}
{% for g in groups %} {% for g in groups %}
<div class="container-fluid page my-3"> <div class="container-fluid page my-3">
<div class="print-header"> <div class="print-header">
@@ -64,7 +72,12 @@
<td class="center">{{ wi.date|date:"d.m.y" }}</td> <td class="center">{{ wi.date|date:"d.m.y" }}</td>
<td class="center">{{ wi.deal.number|default:"-" }}</td> <td class="center">{{ wi.deal.number|default:"-" }}</td>
<td>{{ wi.operation.name|default:wi.stage|default:"—" }}</td> <td>{{ wi.operation.name|default:wi.stage|default:"—" }}</td>
<td>{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}</td> <td>
{{ wi.entity.drawing_number|default:"—" }} {{ wi.entity.name }}
{% if wi.entity.blank_length_mm and not wi.entity.dxf_file and not wi.entity.pdf_main %}
<div class="small text-muted">Длина: {{ wi.entity.blank_length_mm|floatformat:0 }} мм</div>
{% endif %}
</td>
<td> <td>
{% if wi.entity.planned_material %} {% if wi.entity.planned_material %}
{{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }} {{ wi.entity.planned_material.full_name|default:wi.entity.planned_material.name }}

View File

@@ -28,12 +28,18 @@
{% endif %} {% endif %}
{% if user_role in 'admin,technologist,master,clerk' %} {% if user_role in 'admin,technologist,master,clerk' %}
{% if workitem.entity.entity_type == 'product' or workitem.entity.entity_type == 'assembly' %} {% if is_first_operation and workitem.entity.entity_type == 'product' or is_first_operation and workitem.entity.entity_type == 'assembly' %}
<a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}"> <a class="btn btn-outline-accent btn-sm" href="{% url 'workitem_kitting' workitem.id %}?next={{ request.get_full_path|urlencode }}">
<i class="bi bi-box-seam me-1"></i>Комплектация <i class="bi bi-box-seam me-1"></i>Комплектация
</a> </a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if user_role in 'admin,technologist,master,operator,prod_head' %}
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="В разработке">
<i class="bi bi-basket3 me-1"></i>Доп. расходы
</button>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -8,7 +8,12 @@
<h3 class="text-accent mb-0"> <h3 class="text-accent mb-0">
<i class="bi bi-check2-square me-2"></i>Закрытие операции <i class="bi bi-check2-square me-2"></i>Закрытие операции
</h3> </h3>
<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>
<div class="card-body p-4"> <div class="card-body p-4">
@@ -25,6 +30,65 @@
Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения. Эта операция не списывает сырьё/комплектующие. Здесь фиксируется только факт выполнения.
</div> </div>
{% if move_hint and work_location %}
<div class="alert alert-warning border-warning">
На складе участка <strong>{{ work_location.name }}</strong> не хватает изделий для закрытия операции.
Нужно: <strong>{{ move_hint.needed }}</strong> · доступно: <strong>{{ move_hint.available }}</strong> · не хватает: <strong>{{ move_hint.missing }}</strong>.
</div>
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-outline-accent" data-bs-toggle="modal" data-bs-target="#moveToWorkLocationModal" {% if not move_candidates %}disabled{% endif %}>
<i class="bi bi-arrow-left-right me-1"></i>Переместить на участок
</button>
{% if not move_candidates %}
<div class="small text-muted align-self-center">Нет доступных позиций для перемещения</div>
{% endif %}
</div>
<div class="modal fade" id="moveToWorkLocationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<form method="post" action="{% url 'warehouse_transfer' %}" class="modal-content border-secondary">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<input type="hidden" name="to_location_id" value="{{ work_location.id }}">
<div class="modal-header border-secondary">
<h5 class="modal-title">Перемещение на участок</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<div class="fw-bold">{{ workitem.entity.drawing_number|default:"—" }} {{ workitem.entity.name }}</div>
<div class="small text-muted">Склад участка: {{ work_location.name }}</div>
</div>
<div class="row g-2">
<div class="col-md-8">
<label class="form-label">Откуда (позиция склада)</label>
<select class="form-select" name="stock_item_id" required>
{% for c in move_candidates %}
<option value="{{ c.id }}">{{ c.location.name }} · доступно {{ c.quantity|floatformat:"-g" }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Количество</label>
<input class="form-control" name="quantity" inputmode="decimal" value="{{ move_hint.missing }}" required>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-outline-accent">Переместить</button>
</div>
</form>
</div>
</div>
{% endif %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="row align-items-end g-2"> <div class="row align-items-end g-2">

View File

@@ -41,6 +41,7 @@ from .views import (
WorkItemKittingPrintView, WorkItemKittingPrintView,
AssemblyClosingView, AssemblyClosingView,
WorkItemRegistryPrintView, WorkItemRegistryPrintView,
WorkItemRegistryDownloadView,
RegistryView, RegistryView,
SteelGradesCatalogView, SteelGradesCatalogView,
SteelGradeUpsertView, SteelGradeUpsertView,
@@ -111,6 +112,7 @@ urlpatterns = [
# Печать сменного листа # Печать сменного листа
path('registry/print/', RegistryPrintView.as_view(), name='registry_print'), path('registry/print/', RegistryPrintView.as_view(), name='registry_print'),
path('registry/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'), path('registry/workitems/print/', WorkItemRegistryPrintView.as_view(), name='registry_workitems_print'),
path('registry/workitems/download/', WorkItemRegistryDownloadView.as_view(), name='registry_workitems_download'),
path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'), path('item/<int:pk>/', ItemUpdateView.as_view(), name='item_detail'),
path('workitem/<int:pk>/', WorkItemDetailView.as_view(), name='workitem_detail'), path('workitem/<int:pk>/', WorkItemDetailView.as_view(), name='workitem_detail'),
path('workitem/<int:pk>/op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'), path('workitem/<int:pk>/op_closing/', WorkItemOpClosingView.as_view(), name='workitem_op_closing'),

View File

@@ -17,7 +17,7 @@ from django.db import close_old_connections, transaction
from django.db.models import Case, ExpressionWrapper, F, IntegerField, Max, Sum, Value, When from django.db.models import Case, ExpressionWrapper, F, IntegerField, Max, Sum, Value, When
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views import View from django.views import View
@@ -30,6 +30,86 @@ from shiftflow.authz import get_user_group_roles, get_user_roles, primary_role,
logger = logging.getLogger('mes') logger = logging.getLogger('mes')
def _build_registry_workitems_queryset(request, *, role: str, profile):
"""Возвращает queryset WorkItem, соответствующий фильтрам страницы реестра.
Это ровно та же выборка, которая используется для таблицы WorkItem на странице
[registry] (см. RegistryView.get_context_data). Нужна для печати/выгрузок,
чтобы брать задания «как на экране».
"""
qs = WorkItem.objects.select_related(
'deal',
'deal__company',
'entity',
'entity__planned_material',
'operation',
'machine',
'workshop',
)
q = (request.GET.get('q') or '').strip()
if q:
qs = qs.filter(
Q(deal__number__icontains=q)
| Q(entity__name__icontains=q)
| Q(entity__drawing_number__icontains=q)
| Q(entity__planned_material__name__icontains=q)
| Q(entity__planned_material__full_name__icontains=q)
)
m_ids = [int(i) for i in request.GET.getlist('m_ids') if str(i).isdigit()]
if m_ids:
qs = qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True))
filtered = request.GET.get('filtered')
reset = request.GET.get('reset')
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate()
week_ago = today - timezone.timedelta(days=7)
qs = qs.filter(date__gte=week_ago, date__lte=today)
else:
start_date = (request.GET.get('start_date') or '').strip()
end_date = (request.GET.get('end_date') or '').strip()
if start_date:
qs = qs.filter(date__gte=start_date)
if end_date:
qs = qs.filter(date__lte=end_date)
statuses = request.GET.getlist('statuses')
if is_default:
qs = qs.filter(status__in=['planned'])
else:
if not statuses:
qs = qs.none()
else:
expanded = []
for s in statuses:
if s == 'work':
expanded += ['planned']
elif s == 'leftover':
expanded.append('leftover')
elif s == 'closed':
expanded.append('done')
if expanded:
qs = qs.filter(status__in=expanded)
if role == 'operator':
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if allowed_ws:
qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
else:
user_machines = profile.machines.all() if profile else Machine.objects.none()
qs = qs.filter(machine__in=user_machines)
elif role == 'master':
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if allowed_ws:
qs = qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
return qs
def _reconcile_default_delivery_batch(deal_id: int) -> None: def _reconcile_default_delivery_batch(deal_id: int) -> None:
deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity')) deal_items = list(DealItem.objects.filter(deal_id=deal_id).values_list('entity_id', 'quantity'))
if not deal_items: if not deal_items:
@@ -111,6 +191,8 @@ from warehouse.models import Location, Material, MaterialCategory, SteelGrade, S
from warehouse.services.transfers import receive_transfer from warehouse.services.transfers import receive_transfer
from shiftflow.services.closing import apply_closing, apply_closing_workitems from shiftflow.services.closing import apply_closing, apply_closing_workitems
from shiftflow.services.route_flow import advance_progress_and_generate_next_workitem
from shiftflow.services.workitem_registry_export import build_workitem_registry_export_zip
from shiftflow.services.bom_explosion import ( from shiftflow.services.bom_explosion import (
explode_deal, explode_deal,
explode_roots_additive, explode_roots_additive,
@@ -522,65 +604,7 @@ class RegistryView(LoginRequiredMixin, ListView):
it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning' it.fact_bar_class = 'bg-success' if it.status in ['done', 'partial'] else 'bg-warning'
context['items'] = items context['items'] = items
work_qs = WorkItem.objects.select_related('deal', 'deal__company', 'entity', 'entity__planned_material', 'operation', 'machine', 'workshop') work_qs = _build_registry_workitems_queryset(self.request, role=str(role or ''), profile=profile)
q = (self.request.GET.get('q') or '').strip()
if q:
work_qs = work_qs.filter(
Q(deal__number__icontains=q)
| Q(entity__name__icontains=q)
| Q(entity__drawing_number__icontains=q)
| Q(entity__planned_material__name__icontains=q)
| Q(entity__planned_material__full_name__icontains=q)
)
m_ids = [int(i) for i in self.request.GET.getlist('m_ids') if str(i).isdigit()]
if m_ids:
work_qs = work_qs.filter(Q(machine_id__in=m_ids) | Q(machine_id__isnull=True))
filtered = self.request.GET.get('filtered')
reset = self.request.GET.get('reset')
is_default = (not filtered) or bool(reset)
if is_default:
today = timezone.localdate()
week_ago = today - timezone.timedelta(days=7)
work_qs = work_qs.filter(date__gte=week_ago, date__lte=today)
else:
if context.get('start_date'):
work_qs = work_qs.filter(date__gte=context['start_date'])
if context.get('end_date'):
work_qs = work_qs.filter(date__lte=context['end_date'])
statuses = self.request.GET.getlist('statuses')
if is_default:
work_qs = work_qs.filter(status__in=['planned'])
else:
if not statuses:
work_qs = work_qs.none()
else:
expanded = []
for s in statuses:
if s == 'work':
expanded += ['planned']
elif s == 'leftover':
expanded.append('leftover')
elif s == 'closed':
expanded.append('done')
if expanded:
work_qs = work_qs.filter(status__in=expanded)
if role == 'operator':
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if allowed_ws:
work_qs = work_qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
else:
user_machines = profile.machines.all() if profile else Machine.objects.none()
work_qs = work_qs.filter(machine__in=user_machines)
elif role == 'master':
allowed_ws = list(profile.allowed_workshops.values_list('id', flat=True)) if profile else []
if allowed_ws:
work_qs = work_qs.filter(Q(workshop_id__in=allowed_ws) | Q(machine__workshop_id__in=allowed_ws))
workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000]) workitems = list(work_qs.order_by('-date', 'deal__number', 'id')[:2000])
for wi in workitems: for wi in workitems:
@@ -1282,9 +1306,65 @@ class WorkItemRegistryPrintView(LoginRequiredMixin, TemplateView):
g['items'].append(wi) g['items'].append(wi)
ctx['groups'] = list(groups.values()) ctx['groups'] = list(groups.values())
ctx['printed_at'] = timezone.now()
print_date_raw = end_date or start_date
print_date = None
if isinstance(print_date_raw, str) and print_date_raw:
try:
print_date = datetime.strptime(print_date_raw, '%Y-%m-%d').date()
except ValueError:
print_date = None
ctx['print_date'] = print_date or timezone.localdate()
return ctx return ctx
class WorkItemRegistryDownloadView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/registry_workitems_download.html'
def dispatch(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
if not has_any_role(roles, ['admin', 'operator', 'master', 'technologist', 'clerk', 'prod_head', 'director', 'observer']):
return redirect('index')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
roles = get_user_group_roles(self.request.user)
ctx['user_roles'] = sorted(roles)
ctx['user_role'] = primary_role(roles)
ctx['only_work_deals'] = True
return ctx
def post(self, request, *args, **kwargs):
roles = get_user_group_roles(request.user)
role = primary_role(roles)
profile = getattr(request.user, 'profile', None)
translit = (request.POST.get('translit') or '').strip() in ['1', 'true', 'on', 'yes']
try:
qs = _build_registry_workitems_queryset(request, role=str(role or ''), profile=profile)
qs = qs.filter(deal__status='work', status='planned')
workitem_ids = list(qs.values_list('id', flat=True))
zip_bytes, filename = build_workitem_registry_export_zip(
request=request,
workitem_ids=workitem_ids,
translit=bool(translit),
only_work_deals=True,
)
except Exception as e:
logger.exception('workitem_registry_download:error user_id=%s', getattr(request.user, 'id', None))
messages.error(request, f'Ошибка формирования архива: {type(e).__name__}: {e}')
url = f"{reverse_lazy('registry_workitems_download')}?{request.GET.urlencode()}" if request.GET else str(reverse_lazy('registry_workitems_download'))
return redirect(url)
resp = HttpResponse(zip_bytes, content_type='application/zip')
resp['Content-Disposition'] = f'attachment; filename="{filename}"'
return resp
class WorkItemEntityListView(LoginRequiredMixin, TemplateView): class WorkItemEntityListView(LoginRequiredMixin, TemplateView):
template_name = 'shiftflow/workitem_entity_list.html' template_name = 'shiftflow/workitem_entity_list.html'
@@ -1360,6 +1440,9 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
if wi.entity and wi.entity.entity_type in ['product', 'assembly']: if wi.entity and wi.entity.entity_type in ['product', 'assembly']:
return redirect('assembly_closing', pk=wi.id) return redirect('assembly_closing', pk=wi.id)
if wi.entity and wi.entity.entity_type == 'part': if wi.entity and wi.entity.entity_type == 'part':
if getattr(wi.entity, 'planned_material_id', None) and not wi.machine_id:
messages.error(request, 'Для закрытия операции нужно выбрать пост/станок в сменном задании.')
return redirect('workitem_detail', pk=wi.id)
if wi.machine_id and getattr(wi.entity, 'planned_material_id', None): if wi.machine_id and getattr(wi.entity, 'planned_material_id', None):
return redirect(f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}") return redirect(f"{reverse_lazy('closing')}?machine_id={int(wi.machine_id)}&material_id={int(wi.entity.planned_material_id)}")
@@ -1377,6 +1460,51 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
ctx['workitem'] = wi ctx['workitem'] = wi
ctx['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0)) ctx['remaining'] = max(0, int(wi.quantity_plan or 0) - int(wi.quantity_done or 0))
ctx['is_first_operation'] = bool(self.is_first_operation) ctx['is_first_operation'] = bool(self.is_first_operation)
work_location = get_work_location_for_workitem(wi)
ctx['work_location'] = work_location
hint = (self.request.session.get('op_closing_move_hint') or None)
if hint and int(hint.get('workitem_id') or 0) == int(wi.id):
try:
self.request.session.pop('op_closing_move_hint', None)
except Exception:
pass
needed = int(hint.get('qty') or 0)
available = int(hint.get('available') or 0)
missing = max(0, needed - available)
candidates = []
if work_location and missing > 0:
qs = (
StockItem.objects.select_related('location')
.filter(is_archived=False, quantity__gt=0)
.filter(entity_id=int(wi.entity_id))
.exclude(location_id=int(work_location.id))
.filter(Q(deal_id=wi.deal_id) | Q(deal_id__isnull=True))
.annotate(
prio=Case(
When(deal_id=wi.deal_id, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
)
.order_by('prio', 'created_at', 'id')
)
for si in list(qs[:30]):
candidates.append({'id': int(si.id), 'location': si.location, 'quantity': float(si.quantity)})
ctx['move_hint'] = {
'needed': int(needed),
'available': int(available),
'missing': int(missing),
}
ctx['move_candidates'] = candidates
else:
ctx['move_hint'] = None
ctx['move_candidates'] = []
return ctx return ctx
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -1409,7 +1537,8 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
)['s'] )['s']
available_i = int(available or 0) available_i = int(available or 0)
if qty > available_i: if qty > available_i:
messages.error(request, f'Нельзя закрыть операцию: на складе участка «{work_location.name}» доступно {available_i} шт. Сначала перемести изделие на участок.') request.session['op_closing_move_hint'] = {'workitem_id': int(wi.id), 'qty': int(qty), 'available': int(available_i)}
messages.error(request, f'На складе участка «{work_location.name}» нет нужного количества: доступно {available_i} шт. Перемести изделие на участок и повтори закрытие.')
return redirect('workitem_op_closing', pk=wi.id) return redirect('workitem_op_closing', pk=wi.id)
plan_total = int(wi.quantity_plan or 0) plan_total = int(wi.quantity_plan or 0)
@@ -1468,8 +1597,11 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
if int(total_done or 0) >= int(target_qty): if int(total_done or 0) >= int(target_qty):
progress.current_seq = cur + 1 progress.current_seq = cur + 1
progress.save(update_fields=['current_seq']) progress.save(update_fields=['current_seq'])
advance_progress_and_generate_next_workitem(workitem_id=int(wi.id))
messages.success(request, f'Закрыто: {qty} шт.') messages.success(request, f'Закрыто: {qty} шт.')
if (wi.status or '') == 'done':
return redirect('registry')
return redirect('workitem_detail', pk=wi.id) return redirect('workitem_detail', pk=wi.id)
@@ -5244,7 +5376,7 @@ class AssemblyClosingView(LoginRequiredMixin, TemplateView):
try: try:
apply_assembly_closing(self.workitem.id, qty, request.user.id) apply_assembly_closing(self.workitem.id, qty, request.user.id)
messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.') messages.success(request, f'Успешно закрыто {qty} шт. Компоненты списаны, выпуск добавлен.')
return redirect('workitem_detail', pk=self.workitem.id) return redirect('registry')
except Exception as e: except Exception as e:
logger.exception('assembly_closing: error') logger.exception('assembly_closing: error')
messages.error(request, f'Ошибка закрытия: {e}') messages.error(request, f'Ошибка закрытия: {e}')