Compare commits
2 Commits
c558eb1416
...
6da7b775c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da7b775c7 | |||
| 3efd8e5060 |
@@ -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.
|
Контекст: mini‑MES / оперативное управление производством для металлообработки. Приложения: 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)
|
### UI
|
||||||
- Использовать Django Groups как роли приложения (мульти-роли).
|
- Если добавляется новая UI‑таблица — по умолчанию делать сортируемой (если это не мешает UX).
|
||||||
- Имена групп должны совпадать с кодами ролей, используемых в коде, например:
|
|
||||||
- operator
|
|
||||||
- master
|
|
||||||
- technologist
|
|
||||||
- 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
23
CHANGELOG.md
Normal 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 и процесс ведения истории изменений.
|
||||||
@@ -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 везде, кроме сервера
|
||||||
|
|||||||
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 — основной признак операции (расширяемый справочник).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 для задания.')
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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'])
|
||||||
|
|
||||||
|
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()
|
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
|
target_qty = int(deal_item.quantity) if deal_item else None
|
||||||
if ordered_qty is not 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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user