Доработал закрытие, техоперации автоматом переключаются, добавил выгрузку сменных заданий
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

@@ -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',

View File

@@ -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:

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">
<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">

View File

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

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

View File

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

View File

@@ -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">

View File

@@ -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'),

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 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}')