Compare commits

...

2 Commits

Author SHA1 Message Date
6da7b775c7 Ввел логику сделок через партии
All checks were successful
Deploy MES Core / deploy (push) Successful in 4m16s
2026-04-22 08:35:07 +03:00
3efd8e5060 Поднастроил правила и ввел чейнджлог 2026-04-16 23:43:58 +03:00
15 changed files with 559 additions and 116 deletions

View File

@@ -1,90 +1,67 @@
# AI_RULES — правила работы ассистента в проекте MES_Core
Роль: Ты Senior Django Backend Developer.
# AI_RULES — MES_Core
Роль: Senior Django Backend Developer.
Контекст: Разрабатывается MES/ERP система для металлообрабатывающего завода. Архитектура БД разделена на 3 приложения: warehouse, manufacturing, shiftflow.
Контекст: miniMES / оперативное управление производством для металлообработки. Приложения: warehouse, manufacturing, shiftflow.
Задача: Разработать слой бизнес-логики (сервисы и CBV Views) для реализации сквозного процесса производства.
## MUST — правила, которые нельзя нарушать
# AI_RULES — правила работы ассистента в проекте MES_Core
### Коммуникация
- Писать по‑русски.
## 1) Коммуникация
- Пиши по-русски всегда.
### Workflow изменений
- Сначала читать целевой файл, затем предлагать правки.
- Сложная логика живёт в services (service layer), views остаются тонкими.
- Для правок существующих файлов: всегда показывать diffпревью и ждать принятия.
## 2) Изменения в коде
- Сначала читай файл и только потом предлагай правки (чтобы не ломать стиль и импорты).
### Новые файлы
- Для новых файлов всегда давать: полное имя + абсолютный путь + полный контент одним блоком.
## 3) Создание новых файлов
- Для новых файлов звсегда указывай: полное имя, абсолютный путь и весь контент в одном блоке.
### Безопасность
- Никогда не логировать/не печатать: SECRET_KEY, пароли БД, токены.
- В logs — только тех. сообщения/ошибки/диагностика без секретов.
- Для важных справочников/истории в models использовать on_delete=models.PROTECT.
## 4)Комментарии
- В Python/бекенде:
- добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления).
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок.
- В HTML-шаблонах Django:
- не добавляй template-комментарии {# ... #} .
### Транзакции и гонки (warehouse/shiftflow)
- Любые операции списания/начисления/перемещений — в transaction.atomic().
- Изменяемые складские остатки блокировать через select_for_update().
- Избегать N+1: select_related/prefetch_related, bulk операции — только где безопасно.
## 5) Стиль и конвенции проекта
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование).
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте.
- Для UI-таблиц:
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
### Роли и доступ
- Роли приложения — Django Groups (мульти‑роли). Имена групп совпадают с кодами ролей: operator/master/technologist/clerk/supply/prod_head/director/observer/admin.
- Проверка доступа во вьюхах: has_any_role(roles, [...]). primary_role — только для UI.
- На этапе миграции допускается fallback на EmployeeProfile.role, но новые правки ориентировать на группы.
- Использовать Service Layer: сложная логика живет в services.py, вьюхи остаются тонкими.
### Логи
- Для внутренних функций/сервисов: logger = logging.getLogger('mes').
- Перед выполнением: logger.info('fn:start ...').
- После успеха: logger.info('fn:done ...').
- Ошибки: logger.exception('fn:error ...') и пробрасывать дальше.
- Импорты моделей из других приложений — через строковые ссылки в полях ('app.Model') для избежания циклических импортов.
### Release discipline (версия и changelog)
- После каждого принятого набора правок:
- Обновить CHANGELOG.md в секции [Unreleased] (Added/Changed/Fixed).
- В конце ответа всегда писать: “Нужно ли поднять версию? Рекомендация: …”.
- Правило bump (SemVer):
- UI/шаблоны/стили/тексты → PATCH (x.y.Z).
- Логика/вьюхи/сервисы/модели/миграции/доступы → MINOR (x.Y.0).
- Изменения данных/совместимости → обсудить MAJOR (X.0.0), даже если проект ещё в 0.x.
## 6) Безопасность и секреты
- Никогда не логируй и не печатай в stdout:
- SECRET_KEY
- пароли БД
- токены
- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов.
- В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю.
## 7) Логи и фоновые задачи
- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов):
- не блокируй HTTP-ответ
- Использовать модуль threading для запуска таких задач в отдельном потоке.
## SHOULD — правила, которые желательно соблюдать
- Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе.
- Логи фоновых задач должны быть:
- с датой/временем
- доступны из интерфейса “Обслуживание сервера” (tail)
- очищаемы кнопкой (если задача не running)
### Комментарии
- Python/бекенд: добавлять поясняющие комментарии там, где есть бизнес‑логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления).
- Комментарии нейтральные: описывают поведение/причину, без личных формулировок.
- Django HTMLшаблоны: не добавлять templateкомментарии ({# ... #}).
## 8) Транзакции и гонки данных (warehouse/shiftflow)
- Все операции списания/начисления на складе делай в transaction.atomic() .
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
- Для массовых операций избегай N+1:
- select_related / prefetch_related
- bulk update/create там, где это безопасно.
### Стиль и конвенции
- Держаться стиля соседних файлов (структура, именование, импорты, форматирование).
- Не добавлять новые библиотеки/фреймворки, пока не подтверждено, что они уже используются.
## 9) Роли и доступ (Django Groups)
- Использовать Django Groups как роли приложения (мульти-роли).
- Имена групп должны совпадать с кодами ролей, используемых в коде, например:
- operator
- master
- technologist
- clerk
- supply
- prod_head
- director
- observer
- admin
- Назначение ролей в Django admin:
- Users → выбрать пользователя → поле Groups → добавить нужные группы → Save.
- Примечание: на этапе миграции допускается fallback на EmployeeProfile.role, чтобы при деплое до раздачи групп доступ не "слетал".
### UI
- Если добавляется новая UIтаблица — по умолчанию делать сортируемой (если это не мешает UX).
### Назначение станков и цехов пользователю
- Привязка станков/цехов делается через профиль сотрудника:
- Shiftflow → Employee profiles → выбрать профиль пользователя.
- Привязка через профиль сотрудника:
- Machines: закреплённые станки (для операторов).
- Allowed workshops: доступные цеха (ограничение видимости/действий).
- Is readonly: режим "только просмотр".
Правило для новых внутренних функций (как договор):
- Всегда берём логгер logger = logging.getLogger('mes')
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
- На важных шагах — logger.info('fn:step ...', детали)
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше
- Is readonly: режим только просмотр.

23
CHANGELOG.md Normal file
View File

@@ -0,0 +1,23 @@
# Changelog
Все заметные изменения в этом проекте документируются в этом файле.
Формат — по мотивам “Keep a Changelog”, версионирование — SemVer (пока 0.x).
- UI/шаблоны/стили/тексты → увеличиваем PATCH (x.y.Z)
- Логика/вьюхи/сервисы/модели/миграции/доступы → увеличиваем MINOR (x.Y.0)
- Изменения, влияющие на данные/совместимость → обсуждаем MAJOR (X.0.0), даже если проект ещё в 0.x
## [Unreleased]
### Added
-
### Changed
- Производственные задачи и прогресс техпроцесса ведутся в разрезе партий поставки (серий) для одной сделки.
### Fixed
- Запуск «В производство» блокируется, если в BOM есть узлы без техпроцесса (EntityOperation seq=1), чтобы компоненты не попадали в «без техпроцесса».
- Повторный запуск в производство по новой серии не увеличивает объём в уже закрытых задачах прошлых серий.
## [0.7.1] - 2026-04-16
### Added
- Введён CHANGELOG.md и процесс ведения истории изменений.

View File

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

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.3 on 2026-04-22 05:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manufacturing', '0007_remove_productentity_route_delete_routestub'),
('shiftflow', '0034_workitem_quantity_reported_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='dealentityprogress',
unique_together=set(),
),
migrations.AddField(
model_name='dealentityprogress',
name='delivery_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='entity_progress', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'),
),
migrations.AddField(
model_name='productiontask',
name='delivery_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='production_tasks', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'),
),
migrations.AddField(
model_name='workitem',
name='delivery_batch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='workitems', to='shiftflow.dealdeliverybatch', verbose_name='Партия поставки'),
),
migrations.AlterUniqueTogether(
name='dealentityprogress',
unique_together={('deal', 'delivery_batch', 'entity')},
),
]

View File

@@ -97,6 +97,14 @@ class ProductionTask(models.Model):
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name="Сделка")
delivery_batch = models.ForeignKey(
'shiftflow.DealDeliveryBatch',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='production_tasks',
verbose_name='Партия поставки',
)
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, null=True, blank=True, verbose_name="КД (изделие/деталь)")
drawing_name = models.CharField("Название детали", max_length=255, blank=True, default="Б/ч")
@@ -227,20 +235,28 @@ class DealBatchItem(models.Model):
class DealEntityProgress(models.Model):
"""Текущая операция техпроцесса для пары (сделка, сущность).
"""Текущая операция техпроцесса для пары (сделка, партия, сущность).
Комментарий: current_seq=1 означает «выполняем 1-ю операцию в EntityOperation».
Когда current_seq больше числа операций — сущность для сделки считается прошедшей техпроцесс.
Когда current_seq больше числа операций — сущность для партии считается прошедшей техпроцесс.
"""
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
delivery_batch = models.ForeignKey(
'shiftflow.DealDeliveryBatch',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='entity_progress',
verbose_name='Партия поставки',
)
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
current_seq = models.PositiveSmallIntegerField('Текущая операция (порядок)', default=1)
class Meta:
verbose_name = 'Прогресс по операции'
verbose_name_plural = 'Прогресс по операциям'
unique_together = ('deal', 'entity')
unique_together = ('deal', 'delivery_batch', 'entity')
def __str__(self):
return f"{self.deal.number}: {self.entity} -> {self.current_seq}"
@@ -309,6 +325,14 @@ class ProcurementRequirement(models.Model):
class WorkItem(models.Model):
deal = models.ForeignKey(Deal, on_delete=models.CASCADE, verbose_name='Сделка')
delivery_batch = models.ForeignKey(
'shiftflow.DealDeliveryBatch',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='workitems',
verbose_name='Партия поставки',
)
entity = models.ForeignKey('manufacturing.ProductEntity', on_delete=models.PROTECT, verbose_name='Сущность')
# Комментарий: operation — основной признак операции (расширяемый справочник).

View File

@@ -8,7 +8,7 @@ from django.db import models, transaction
from django.db.models import Sum
from django.db.models.functions import Coalesce
from manufacturing.models import BOM, ProductEntity
from manufacturing.models import BOM, EntityOperation, ProductEntity
from shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
from warehouse.models import StockItem
@@ -16,9 +16,19 @@ logger = logging.getLogger('mes')
class ExplosionValidationError(Exception):
def __init__(self, missing_material_ids: list[int]):
super().__init__('missing_material')
def __init__(
self,
*,
missing_material_ids: list[int] | None = None,
missing_route_entity_ids: list[int] | None = None,
):
self.missing_material_ids = [int(x) for x in (missing_material_ids or [])]
self.missing_route_entity_ids = [int(x) for x in (missing_route_entity_ids or [])]
if self.missing_route_entity_ids:
super().__init__('missing_tech_process')
else:
super().__init__('missing_material')
@dataclass(frozen=True)
@@ -338,6 +348,8 @@ def explode_deal(
def explode_roots_additive(
deal_id: int,
roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats:
"""Additive BOM Explosion для запуска в производство по частям.
@@ -370,13 +382,34 @@ def explode_roots_additive(
.filter(id__in=list(required_nodes.keys()))
}
missing = [
missing_material = [
int(e.id)
for e in entities.values()
if (getattr(e, 'entity_type', '') == 'part' and not getattr(e, 'planned_material_id', None) and int(required_nodes.get(int(e.id), 0) or 0) > 0)
if (
(getattr(e, 'entity_type', '') == 'part')
and not getattr(e, 'planned_material_id', None)
and int(required_nodes.get(int(e.id), 0) or 0) > 0
)
]
if missing:
raise ExplosionValidationError(missing)
if missing_material:
raise ExplosionValidationError(missing_material_ids=missing_material)
internal_types = {'part', 'assembly', 'product'}
internal_ids = [
int(e.id)
for e in entities.values()
if (
(getattr(e, 'entity_type', '') or '').strip() in internal_types
and int(required_nodes.get(int(e.id), 0) or 0) > 0
)
]
if internal_ids:
routed = set(
EntityOperation.objects.filter(entity_id__in=internal_ids, seq=1).values_list('entity_id', flat=True)
)
missing_route = sorted({int(x) for x in internal_ids} - {int(x) for x in routed})
if missing_route:
raise ExplosionValidationError(missing_route_entity_ids=missing_route)
tasks_created = 0
tasks_updated = 0
@@ -408,6 +441,7 @@ def explode_roots_additive(
pt, created = ProductionTask.objects.get_or_create(
deal=deal,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity=entity,
defaults=defaults,
)
@@ -427,8 +461,9 @@ def explode_roots_additive(
tasks_updated += 1
logger.info(
'explode_roots_additive: deal_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s',
'explode_roots_additive: deal_id=%s batch_id=%s roots=%s nodes=%s tasks_created=%s tasks_updated=%s skipped_no_material=%s skipped_supply=%s',
deal_id,
delivery_batch_id,
roots,
len(required_nodes),
tasks_created,
@@ -443,6 +478,8 @@ def explode_roots_additive(
def rollback_roots_additive(
deal_id: int,
roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats:
"""Откат additive BOM Explosion.
@@ -485,7 +522,11 @@ def rollback_roots_additive(
skipped_supply += 1
continue
pt = ProductionTask.objects.filter(deal=deal, entity=entity).first()
pt = ProductionTask.objects.filter(
deal=deal,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity=entity,
).first()
if not pt:
missing_tasks += 1
continue
@@ -501,8 +542,9 @@ def rollback_roots_additive(
tasks_updated += 1
logger.info(
'rollback_roots_additive: deal_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s',
'rollback_roots_additive: deal_id=%s batch_id=%s roots=%s nodes=%s tasks_updated=%s skipped_supply=%s missing_tasks=%s',
deal_id,
delivery_batch_id,
roots,
len(required_nodes),
tasks_updated,

View File

@@ -185,7 +185,12 @@ def apply_closing_workitems(
if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 0.')
pt = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
pt_qs = ProductionTask.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id)
if getattr(wi, 'delivery_batch_id', None):
pt_qs = pt_qs.filter(delivery_batch_id=wi.delivery_batch_id)
else:
pt_qs = pt_qs.filter(delivery_batch_id__isnull=True)
pt = pt_qs.first()
if not pt:
raise RuntimeError('Не найден ProductionTask для задания.')

View File

@@ -373,6 +373,7 @@
data-bs-toggle="modal"
data-bs-target="#workItemModal"
data-entity-id="{{ t.entity_id }}"
data-batch-id="{{ t.delivery_batch_id|default:'' }}"
data-operation-id="{{ t.current_operation_id }}"
data-workshop-id="{{ t.current_workshop_id|default:'' }}"
data-workshop-name="{{ t.current_workshop_name|default:'' }}"
@@ -458,6 +459,7 @@
</div>
<div class="modal-body">
<input type="hidden" name="entity_id" id="wiEntityId">
<input type="hidden" name="delivery_batch_id" id="wiBatchId">
<input type="hidden" name="operation_id" id="wiOperationId">
<div class="small text-muted mb-2" id="wiTitle"></div>
@@ -941,12 +943,14 @@ document.addEventListener('DOMContentLoaded', function () {
modal.addEventListener('shown.bs.modal', function (event) {
const btn = event.relatedTarget;
const entityId = btn.getAttribute('data-entity-id') || '';
const batchId = btn.getAttribute('data-batch-id') || '';
const opId = btn.getAttribute('data-operation-id') || '';
const name = btn.getAttribute('data-task-name') || '';
const opName = btn.getAttribute('data-operation-name') || '';
const rem = btn.getAttribute('data-task-rem');
document.getElementById('wiEntityId').value = entityId;
document.getElementById('wiBatchId').value = batchId;
document.getElementById('wiOperationId').value = opId;
document.getElementById('wiTitle').textContent = name;

View File

@@ -594,4 +594,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -275,4 +275,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -24,7 +24,7 @@
</div>
</div>
<div class="card-body">
<form method="post" action="{% url 'product_info' entity.id %}" class="container-fluid p-0">
<form method="post" action="{% url 'product_info' entity.id %}" class="container-fluid p-0" id="product-info-form">
{% csrf_token %}
<input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}">
@@ -64,4 +64,44 @@
</form>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -275,4 +275,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -310,4 +310,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -270,4 +270,44 @@
</div>
</div>
</div>
{{ next|json_script:"productInfoNext" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('product-info-form');
function getNextUrl() {
const el = document.getElementById('productInfoNext');
return el ? JSON.parse(el.textContent || '""') : '';
}
function submitForm() {
if (!form) return;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (e.defaultPrevented) return;
if (document.querySelector('.modal.show')) return;
const url = getNextUrl();
if (url) {
e.preventDefault();
window.location.href = url;
}
return;
}
const key = (e.key || '').toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 's') {
e.preventDefault();
submitForm();
}
}, true);
});
</script>
{% endblock %}

View File

@@ -962,9 +962,20 @@ class WorkItemUpdateView(LoginRequiredMixin, View):
wi.save(update_fields=list(dict.fromkeys(changed_fields)))
# Комментарий: автоматический переход на следующую операцию по маршруту для пары (сделка, сущность).
# Сдвигаем только когда выполнено количество по позиции сделки.
if ordered_qty is not None:
# Комментарий: автоматический переход на следующую операцию по маршруту.
# Для партийного производства двигаем прогресс в рамках партии, ориентируясь на объём серии.
target_qty = None
if getattr(wi, 'delivery_batch_id', None):
target_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()
else:
deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
target_qty = int(deal_item.quantity) if deal_item else None
if target_qty is not None:
op_code = None
if getattr(wi, 'operation_id', None):
op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first()
@@ -972,13 +983,26 @@ class WorkItemUpdateView(LoginRequiredMixin, View):
op_code = (wi.stage or '').strip()
if op_code:
progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1})
progress, _ = DealEntityProgress.objects.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 cur_eo and cur_eo.operation and cur_eo.operation.code == op_code:
total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
if int(total_done or 0) >= int(ordered_qty):
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):
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
@@ -1396,9 +1420,18 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
wi.status = 'planned'
wi.save(update_fields=['quantity_done', 'status'])
target_qty = None
if getattr(wi, 'delivery_batch_id', None):
target_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()
else:
deal_item = DealItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).first()
ordered_qty = int(deal_item.quantity) if deal_item else None
if ordered_qty is not None:
target_qty = int(deal_item.quantity) if deal_item else None
if target_qty is not None:
op_code = None
if getattr(wi, 'operation_id', None):
op_code = Operation.objects.filter(pk=wi.operation_id).values_list('code', flat=True).first()
@@ -1406,12 +1439,25 @@ class WorkItemOpClosingView(LoginRequiredMixin, TemplateView):
op_code = (wi.stage or '').strip()
if op_code:
progress, _ = DealEntityProgress.objects.get_or_create(deal_id=wi.deal_id, entity_id=wi.entity_id, defaults={'current_seq': 1})
progress, _ = DealEntityProgress.objects.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 cur_eo and cur_eo.operation and cur_eo.operation.code == op_code:
total_done = WorkItem.objects.filter(deal_id=wi.deal_id, entity_id=wi.entity_id).filter(Q(operation__code=op_code) | Q(stage=op_code)).aggregate(s=Coalesce(Sum('quantity_done'), 0))['s']
if int(total_done or 0) >= int(ordered_qty):
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):
progress.current_seq = cur + 1
progress.save(update_fields=['current_seq'])
@@ -2052,14 +2098,22 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
tasks = list(
ProductionTask.objects.filter(deal=deal)
.select_related('material', 'entity')
.select_related('material', 'entity', 'delivery_batch')
.order_by('-id')
)
task_entity_ids = {int(x.entity_id) for x in tasks if getattr(x, 'entity_id', None)}
task_batch_ids = {int(x.delivery_batch_id) for x in tasks if getattr(x, 'delivery_batch_id', None)}
progress_qs = DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids))
if task_batch_ids:
progress_qs = progress_qs.filter(Q(delivery_batch_id__in=list(task_batch_ids)) | Q(delivery_batch_id__isnull=True))
else:
progress_qs = progress_qs.filter(delivery_batch_id__isnull=True)
progress_task_map = {
int(p.entity_id): int(p.current_seq or 1)
for p in DealEntityProgress.objects.filter(deal=deal, entity_id__in=list(task_entity_ids))
((int(p.delivery_batch_id) if getattr(p, 'delivery_batch_id', None) else None), int(p.entity_id)): int(p.current_seq or 1)
for p in progress_qs
}
ops_task_map = {}
for eo in (
@@ -2078,7 +2132,8 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
if not getattr(t, 'entity_id', None):
continue
seq = int(progress_task_map.get(int(t.entity_id), 1) or 1)
batch_key = int(t.delivery_batch_id) if getattr(t, 'delivery_batch_id', None) else None
seq = int(progress_task_map.get((batch_key, int(t.entity_id)), 1) or 1)
eo = ops_task_map.get((int(t.entity_id), seq))
if not eo:
continue
@@ -2093,8 +2148,12 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
wi_qs = wi_qs.filter(workshop_id__in=allowed_ws)
wi_sums = {
(int(r['entity_id']), int(r['operation_id'])): (int(r['planned'] or 0), int(r['done'] or 0))
for r in wi_qs.values('entity_id', 'operation_id').annotate(
(
(int(r['delivery_batch_id']) if r['delivery_batch_id'] is not None else None),
int(r['entity_id']),
int(r['operation_id']),
): (int(r['planned'] or 0), int(r['done'] or 0))
for r in wi_qs.values('delivery_batch_id', 'entity_id', 'operation_id').annotate(
planned=Coalesce(Sum('quantity_plan'), 0),
done=Coalesce(Sum('quantity_done'), 0),
)
@@ -2108,7 +2167,11 @@ class DealPlanningView(LoginRequiredMixin, TemplateView):
need = int(t.quantity_ordered or 0)
key = None
if getattr(t, 'entity_id', None) and getattr(t, 'current_operation_id', None):
key = (int(t.entity_id), int(t.current_operation_id))
key = (
(int(t.delivery_batch_id) if getattr(t, 'delivery_batch_id', None) else None),
int(t.entity_id),
int(t.current_operation_id),
)
planned_qty, done_qty = wi_sums.get(key, (0, 0)) if key else (0, 0)
planned_qty = int(planned_qty or 0)
@@ -2649,6 +2712,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
return int(s) if s.isdigit() else None
deal_id = parse_int(request.POST.get('deal_id'))
delivery_batch_id = parse_int(request.POST.get('delivery_batch_id'))
entity_id = parse_int(request.POST.get('entity_id'))
operation_id = parse_int(request.POST.get('operation_id'))
machine_id = parse_int(request.POST.get('machine_id'))
@@ -2703,7 +2767,11 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
progress_map = {
int(p.entity_id): int(p.current_seq or 1)
for p in DealEntityProgress.objects.filter(deal_id=int(deal_id), entity_id__in=node_ids)
for p in DealEntityProgress.objects.filter(
deal_id=int(deal_id),
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity_id__in=node_ids,
)
}
ops_map = {
@@ -2718,6 +2786,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
if int(c_id) == int(entity_id):
WorkItem.objects.create(
deal_id=deal_id,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity_id=entity_id,
operation_id=operation_id,
workshop_id=resolved_workshop_id,
@@ -2739,6 +2808,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
cur_op = eo.operation
WorkItem.objects.create(
deal_id=deal_id,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity_id=int(c_id),
operation_id=int(cur_op.id),
workshop_id=(int(cur_op.workshop_id) if getattr(cur_op, 'workshop_id', None) else None),
@@ -2758,6 +2828,7 @@ class WorkItemPlanAddView(LoginRequiredMixin, View):
else:
wi = WorkItem.objects.create(
deal_id=int(deal_id),
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity_id=int(entity_id),
operation_id=int(operation_id),
workshop_id=resolved_workshop_id,
@@ -3281,7 +3352,11 @@ class DealBatchActionView(LoginRequiredMixin, View):
messages.error(request, 'Нельзя откатить: по этой позиции уже есть постановка в смену (план/факт).')
return redirect(next_url)
stats = rollback_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))])
stats = rollback_roots_additive(
int(deal_id),
[(int(bi.entity_id), int(qty))],
delivery_batch_id=int(bi.batch_id),
)
bi.started_qty = started - int(qty)
bi.save(update_fields=['started_qty'])
@@ -3336,7 +3411,11 @@ class DealBatchActionView(LoginRequiredMixin, View):
logger.info('start_batch_item_production: qty_exceeds_remaining remaining=%s started=%s total=%s', remaining, started, total)
return redirect(next_url)
stats = explode_roots_additive(int(deal_id), [(int(bi.entity_id), int(qty))])
stats = explode_roots_additive(
int(deal_id),
[(int(bi.entity_id), int(qty))],
delivery_batch_id=int(bi.batch_id),
)
bi.started_qty = started + int(qty)
bi.save(update_fields=['started_qty'])
@@ -3354,11 +3433,22 @@ class DealBatchActionView(LoginRequiredMixin, View):
)
return redirect(next_url)
except ExplosionValidationError as ev:
try:
from manufacturing.models import ProductEntity
bad = list(ProductEntity.objects.filter(id__in=list(ev.missing_material_ids)).values_list('drawing_number', 'name'))
except Exception:
bad = []
if getattr(ev, 'missing_route_entity_ids', None):
bad = list(
ProductEntity.objects.filter(id__in=list(ev.missing_route_entity_ids)).values_list('id', 'drawing_number', 'name')
)
if bad:
preview = ", ".join([f"/products/{eid}/info/ — {dn or ''} {nm}" for eid, dn, nm in bad[:5]])
more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}"
messages.error(
request,
f'Нельзя запустить в производство: нет техпроцесса (операция seq=1) у: {preview}{more}. Добавь техпроцесс и повтори запуск.',
)
else:
messages.error(request, 'Нельзя запустить в производство: у части позиций отсутствует техпроцесс. Добавь техпроцесс и повтори запуск.')
return redirect(next_url)
bad = list(ProductEntity.objects.filter(id__in=list(getattr(ev, 'missing_material_ids', []) or [])).values_list('drawing_number', 'name'))
if bad:
preview = ", ".join([f"{dn or ''} {nm}" for dn, nm in bad[:5]])
more = '' if len(bad) <= 5 else f" и ещё {len(bad)-5}"