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 # AI_RULES — MES_Core
Роль: Ты Senior Django Backend Developer. Роль: 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)Комментарии ### Транзакции и гонки (warehouse/shiftflow)
- В Python/бекенде: - Любые операции списания/начисления/перемещений — в transaction.atomic().
- добавляй поясняющие комментарии там, где есть бизнес-логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления). - Изменяемые складские остатки блокировать через select_for_update().
- комментарии должны быть нейтральными и описывать поведение/причину, без личных формулировок. - Избегать N+1: select_related/prefetch_related, bulk операции — только где безопасно.
- В HTML-шаблонах Django:
- не добавляй template-комментарии {# ... #} .
## 5) Стиль и конвенции проекта ### Роли и доступ
- Смотри на соседние файлы и придерживайся уже принятого стиля (структура, именование, импорты, форматирование). - Роли приложения — Django Groups (мульти‑роли). Имена групп совпадают с кодами ролей: operator/master/technologist/clerk/supply/prod_head/director/observer/admin.
- Не вводи новые библиотеки/фреймворки, пока не проверил, что они уже используются в проекте. - Проверка доступа во вьюхах: has_any_role(roles, [...]). primary_role — только для UI.
- Для UI-таблиц: - На этапе миграции допускается fallback на EmployeeProfile.role, но новые правки ориентировать на группы.
- если добавляешь новую таблицу — по умолчанию делай её сортируемой (если не мешает UX).
- Использовать 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) Безопасность и секреты ## SHOULD — правила, которые желательно соблюдать
- Никогда не логируй и не печатай в stdout:
- SECRET_KEY
- пароли БД
- токены
- В логи допускаются только технические сообщения, ошибки и диагностические данные без секретов.
- В models.py всегда использовать on_delete=models.PROTECT для важных справочников (Металл, Сделки), чтобы нельзя было случайно удалить историю.
## 7) Логи и фоновые задачи
- Для долгих операций (рендер превью, массовые обновления, BOM explosion для больших заказов):
- не блокируй HTTP-ответ
- Использовать модуль threading для запуска таких задач в отдельном потоке.
- Обязательно оборачивать фоновую функцию в try/except и логировать ошибки в БД или файл, так как ошибки в потоках не видны во вьюхе. ### Комментарии
- Логи фоновых задач должны быть: - Python/бекенд: добавлять поясняющие комментарии там, где есть бизнес‑логика, транзакции, конкурентность, фоновые задачи, сложные алгоритмы (BOM, списания, начисления).
- с датой/временем - Комментарии нейтральные: описывают поведение/причину, без личных формулировок.
- доступны из интерфейса “Обслуживание сервера” (tail) - Django HTMLшаблоны: не добавлять templateкомментарии ({# ... #}).
- очищаемы кнопкой (если задача не running)
## 8) Транзакции и гонки данных (warehouse/shiftflow)
- Все операции списания/начисления на складе делай в transaction.atomic() .
- На изменяемые складские остатки используй select_for_update() чтобы избежать гонок.
- Для массовых операций избегай N+1:
- select_related / prefetch_related
- bulk update/create там, где это безопасно.
## 9) Роли и доступ (Django Groups) ### Стиль и конвенции
- Использовать Django Groups как роли приложения (мульти-роли). - Держаться стиля соседних файлов (структура, именование, импорты, форматирование).
- Имена групп должны совпадать с кодами ролей, используемых в коде, например: - Не добавлять новые библиотеки/фреймворки, пока не подтверждено, что они уже используются.
- operator
- master ### UI
- technologist - Если добавляется новая UIтаблица — по умолчанию делать сортируемой (если это не мешает UX).
- clerk
- supply
- prod_head
- director
- observer
- admin
- Назначение ролей в Django admin:
- Users → выбрать пользователя → поле Groups → добавить нужные группы → Save.
- Примечание: на этапе миграции допускается fallback на EmployeeProfile.role, чтобы при деплое до раздачи групп доступ не "слетал".
### Назначение станков и цехов пользователю ### Назначение станков и цехов пользователю
- Привязка станков/цехов делается через профиль сотрудника: - Привязка через профиль сотрудника:
- Shiftflow → Employee profiles → выбрать профиль пользователя.
- Machines: закреплённые станки (для операторов). - Machines: закреплённые станки (для операторов).
- Allowed workshops: доступные цеха (ограничение видимости/действий). - Allowed workshops: доступные цеха (ограничение видимости/действий).
- Is readonly: режим "только просмотр". - Is readonly: режим только просмотр.
Правило для новых внутренних функций (как договор):
- Всегда берём логгер logger = logging.getLogger('mes')
- Перед выполнением — logger.info('fn:start ...', ключевые параметры)
- После успешного выполнения — logger.info('fn:done ...', ключевые результаты)
- На важных шагах — logger.info('fn:step ...', детали)
- Исключение — с context: logger.exception('fn:error ...') — не глотаем, пробрасываем дальше

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

View File

@@ -8,7 +8,7 @@ from django.db import models, transaction
from django.db.models import Sum from django.db.models import Sum
from django.db.models.functions import Coalesce 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 shiftflow.models import Deal, DealItem, ProcurementRequirement, ProductionTask
from warehouse.models import StockItem from warehouse.models import StockItem
@@ -16,9 +16,19 @@ logger = logging.getLogger('mes')
class ExplosionValidationError(Exception): class ExplosionValidationError(Exception):
def __init__(self, missing_material_ids: list[int]): def __init__(
super().__init__('missing_material') 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_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) @dataclass(frozen=True)
@@ -338,6 +348,8 @@ def explode_deal(
def explode_roots_additive( def explode_roots_additive(
deal_id: int, deal_id: int,
roots: list[tuple[int, int]], roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats: ) -> ExplosionStats:
"""Additive BOM Explosion для запуска в производство по частям. """Additive BOM Explosion для запуска в производство по частям.
@@ -370,13 +382,34 @@ def explode_roots_additive(
.filter(id__in=list(required_nodes.keys())) .filter(id__in=list(required_nodes.keys()))
} }
missing = [ missing_material = [
int(e.id) int(e.id)
for e in entities.values() 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: if missing_material:
raise ExplosionValidationError(missing) 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_created = 0
tasks_updated = 0 tasks_updated = 0
@@ -408,6 +441,7 @@ def explode_roots_additive(
pt, created = ProductionTask.objects.get_or_create( pt, created = ProductionTask.objects.get_or_create(
deal=deal, deal=deal,
delivery_batch_id=(int(delivery_batch_id) if delivery_batch_id else None),
entity=entity, entity=entity,
defaults=defaults, defaults=defaults,
) )
@@ -427,8 +461,9 @@ def explode_roots_additive(
tasks_updated += 1 tasks_updated += 1
logger.info( 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, deal_id,
delivery_batch_id,
roots, roots,
len(required_nodes), len(required_nodes),
tasks_created, tasks_created,
@@ -443,6 +478,8 @@ def explode_roots_additive(
def rollback_roots_additive( def rollback_roots_additive(
deal_id: int, deal_id: int,
roots: list[tuple[int, int]], roots: list[tuple[int, int]],
*,
delivery_batch_id: int | None = None,
) -> ExplosionStats: ) -> ExplosionStats:
"""Откат additive BOM Explosion. """Откат additive BOM Explosion.
@@ -485,7 +522,11 @@ def rollback_roots_additive(
skipped_supply += 1 skipped_supply += 1
continue 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: if not pt:
missing_tasks += 1 missing_tasks += 1
continue continue
@@ -501,8 +542,9 @@ def rollback_roots_additive(
tasks_updated += 1 tasks_updated += 1
logger.info( 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, deal_id,
delivery_batch_id,
roots, roots,
len(required_nodes), len(required_nodes),
tasks_updated, tasks_updated,

View File

@@ -185,7 +185,12 @@ def apply_closing_workitems(
if fact <= 0: if fact <= 0:
raise RuntimeError('При частичном закрытии факт должен быть больше 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: if not pt:
raise RuntimeError('Не найден ProductionTask для задания.') raise RuntimeError('Не найден ProductionTask для задания.')

View File

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

View File

@@ -594,4 +594,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -275,4 +275,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -24,7 +24,7 @@
</div> </div>
</div> </div>
<div class="card-body"> <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 %} {% csrf_token %}
<input type="hidden" name="action" value="save"> <input type="hidden" name="action" value="save">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
@@ -64,4 +64,44 @@
</form> </form>
</div> </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 %} {% endblock %}

View File

@@ -275,4 +275,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -310,4 +310,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

@@ -270,4 +270,44 @@
</div> </div>
</div> </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 %} {% endblock %}

View File

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