feat: 工艺路线支持子图逻辑
This commit is contained in:
parent
a31faa0cda
commit
c002017a84
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 3.2.12 on 2025-03-27 04:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mtm', '0054_process_parent'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='routepack',
|
||||||
|
name='gjson',
|
||||||
|
field=models.JSONField(blank=True, default=dict, verbose_name='子图结构'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='routepack',
|
||||||
|
name='material',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='mtm.material', verbose_name='产品'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -214,11 +214,154 @@ class RoutePack(CommonADModel):
|
||||||
state = models.PositiveSmallIntegerField('状态', default=10, choices=RP_STATE)
|
state = models.PositiveSmallIntegerField('状态', default=10, choices=RP_STATE)
|
||||||
name = models.CharField('名称', max_length=100)
|
name = models.CharField('名称', max_length=100)
|
||||||
material = models.ForeignKey(
|
material = models.ForeignKey(
|
||||||
Material, verbose_name='产品', on_delete=models.CASCADE)
|
Material, verbose_name='产品', on_delete=models.CASCADE, null=True, blank=True)
|
||||||
ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单',
|
ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单',
|
||||||
on_delete=models.SET_NULL, related_name='routepack_ticket', null=True, blank=True, db_constraint=False)
|
on_delete=models.SET_NULL, related_name='routepack_ticket', null=True, blank=True, db_constraint=False)
|
||||||
document = models.ForeignKey("system.file", verbose_name='工艺文件', on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False)
|
document = models.ForeignKey("system.file", verbose_name='工艺文件', on_delete=models.SET_NULL, null=True, blank=True, db_constraint=False)
|
||||||
|
gjson = models.JSONField('子图结构', default=dict, blank=True)
|
||||||
|
|
||||||
|
def validate_and_return_gjson(self, raise_exception=True):
|
||||||
|
"""
|
||||||
|
校验所有子图,返回结构化数据:
|
||||||
|
{
|
||||||
|
final_material_out_id1: {
|
||||||
|
"routes": [1, 2, ...], # 子图关联的Route ID
|
||||||
|
"meta": {
|
||||||
|
"is_valid": True,
|
||||||
|
"errors": [],
|
||||||
|
"levels": {material_id: 层级深度}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
final_material_out_id2: { ... }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
all_routes = Route.objects.filter(routepack=self).order_by('sort', 'process__sort', 'create_time')
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# 1. 找出所有最终产品(未被任何material_in引用的out_id)
|
||||||
|
all_out_ids = {route.material_out.id for route in all_routes}
|
||||||
|
all_in_ids = {route.material_in.id for route in all_routes}
|
||||||
|
final_ids = all_out_ids - all_in_ids
|
||||||
|
|
||||||
|
if not final_ids:
|
||||||
|
raise ParseError("未找到任何最终产品")
|
||||||
|
|
||||||
|
# 4. 为每个最终产品构建子图
|
||||||
|
for final_id in final_ids:
|
||||||
|
graph = defaultdict(list)
|
||||||
|
# BFS收集子图所有material_id
|
||||||
|
visited = set()
|
||||||
|
queue = deque([final_id])
|
||||||
|
subgraph_materials = set()
|
||||||
|
while queue:
|
||||||
|
current_id = queue.popleft()
|
||||||
|
if current_id in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current_id)
|
||||||
|
subgraph_materials.add(current_id)
|
||||||
|
queue.extend(graph.get(current_id, []))
|
||||||
|
|
||||||
|
# 收集子图关联的Route ID
|
||||||
|
subgraph_route_ids = []
|
||||||
|
for route in all_routes:
|
||||||
|
if route.material_out.id in visited:
|
||||||
|
subgraph_route_ids.append(route.id)
|
||||||
|
|
||||||
|
# 校验子图是否有效(无循环、路径可达)
|
||||||
|
is_valid, errors, levels = self._validate_subgraph(subgraph_materials, graph, final_id)
|
||||||
|
if errors and raise_exception:
|
||||||
|
raise ParseError(errors)
|
||||||
|
|
||||||
|
# 记录子图信息
|
||||||
|
result[final_id] = {
|
||||||
|
"routes": subgraph_route_ids,
|
||||||
|
"meta": {
|
||||||
|
"is_valid": is_valid,
|
||||||
|
"errors": errors,
|
||||||
|
"levels": levels # 层级结构,用于生产排程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _validate_subgraph(materials, graph, final_id):
|
||||||
|
"""校验单个子图的有效性并返回层级结构"""
|
||||||
|
errors = []
|
||||||
|
levels = {}
|
||||||
|
|
||||||
|
# 层级计算(从最终物料逆向遍历)
|
||||||
|
queue = deque([(final_id, 0)])
|
||||||
|
while queue:
|
||||||
|
current_id, level = queue.popleft()
|
||||||
|
if current_id in levels:
|
||||||
|
if level > levels[current_id]:
|
||||||
|
levels[current_id] = level
|
||||||
|
continue
|
||||||
|
levels[current_id] = level
|
||||||
|
for in_id in graph.get(current_id, []):
|
||||||
|
queue.append((in_id, level + 1))
|
||||||
|
|
||||||
|
# 检查循环依赖
|
||||||
|
visited = set()
|
||||||
|
path = set()
|
||||||
|
|
||||||
|
def detect_cycle(m_id):
|
||||||
|
if m_id in path:
|
||||||
|
return True
|
||||||
|
if m_id in visited:
|
||||||
|
return False
|
||||||
|
visited.add(m_id)
|
||||||
|
path.add(m_id)
|
||||||
|
for out_id in graph.get(m_id, []):
|
||||||
|
if detect_cycle(out_id):
|
||||||
|
return True
|
||||||
|
path.remove(m_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for m_id in materials:
|
||||||
|
if detect_cycle(m_id):
|
||||||
|
errors.append(f"物料(ID:{m_id})存在循环依赖")
|
||||||
|
return False, errors, {}
|
||||||
|
|
||||||
|
# 检查孤立节点(所有物料必须能到达最终产品)
|
||||||
|
unreachable = materials - set(levels.keys())
|
||||||
|
if unreachable:
|
||||||
|
errors.append(f"存在孤立物料: {unreachable}")
|
||||||
|
return False, errors, {}
|
||||||
|
|
||||||
|
return True, [], levels
|
||||||
|
|
||||||
|
def get_gjson(self, need_update=False, final_material_id=None):
|
||||||
|
if not self.gjson or need_update:
|
||||||
|
self.gjson = self.validate_and_return_gjson()
|
||||||
|
self.save()
|
||||||
|
return self.gjson.get(final_material_id, {}) if final_material_id else self.gjson
|
||||||
|
|
||||||
|
def get_dags(self):
|
||||||
|
gjson = self.get_gjson()
|
||||||
|
finalMaterialIdList = []
|
||||||
|
routeIdList = []
|
||||||
|
for final_material_id, data in gjson.items():
|
||||||
|
finalMaterialIdList.append(final_material_id)
|
||||||
|
routeIdList.extend(data['routes'])
|
||||||
|
mat_qs = Material.objects.filter(id__in=finalMaterialIdList).order_by("process__sort", "create_time")
|
||||||
|
mat_dict = {mat.id: {"id": mat.id, "label": str(mat), "shape": "rect"}for mat in mat_qs}
|
||||||
|
route_qs = Route.objects.filter(id__in=routeIdList).select_related("material_in", "material_out", "process")
|
||||||
|
route_dict = {r.id: {"label": r.process.name, "source": r.material_in.id, "target": r.material_out.id} for r in route_qs}
|
||||||
|
res = {}
|
||||||
|
for final_material_id, data in gjson.items():
|
||||||
|
item = {"name": mat_dict[final_material_id]}
|
||||||
|
edges = []
|
||||||
|
nodes_set = set()
|
||||||
|
for route_id in data['routes']:
|
||||||
|
edges.append(route_dict[route_id])
|
||||||
|
nodes_set.update([route_dict[route_id]['source'], route_dict[route_id]['target']])
|
||||||
|
item['edges'] = edges
|
||||||
|
item['nodes'] = [mat_dict[node_id] for node_id in nodes_set]
|
||||||
|
res[final_material_id] = item
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_final_material_ids(self):
|
||||||
|
return list(self.get_gjson().keys())
|
||||||
class Route(CommonADModel):
|
class Route(CommonADModel):
|
||||||
"""
|
"""
|
||||||
加工路线
|
加工路线
|
||||||
|
@ -246,7 +389,7 @@ class Route(CommonADModel):
|
||||||
parent = models.ForeignKey('self', verbose_name='上级路线', on_delete=models.CASCADE, null=True, blank=True)
|
parent = models.ForeignKey('self', verbose_name='上级路线', on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_routes(material: Material=None, routepack:RoutePack=None):
|
def get_routes(material: Material=None, routepack:RoutePack=None, routeIds=None):
|
||||||
"""
|
"""
|
||||||
返回工艺路线带车间(不关联工艺包)
|
返回工艺路线带车间(不关联工艺包)
|
||||||
"""
|
"""
|
||||||
|
@ -262,6 +405,8 @@ class Route(CommonADModel):
|
||||||
# raise ParseError('未指定统计步骤')
|
# raise ParseError('未指定统计步骤')
|
||||||
elif routepack:
|
elif routepack:
|
||||||
rqs = Route.objects.filter(routepack=routepack).order_by('sort', 'process__sort', 'create_time')
|
rqs = Route.objects.filter(routepack=routepack).order_by('sort', 'process__sort', 'create_time')
|
||||||
|
elif routeIds:
|
||||||
|
rqs = Route.objects.filter(id__in=routeIds).order_by('sort', 'process__sort', 'create_time')
|
||||||
return rqs
|
return rqs
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -363,17 +508,9 @@ class Route(CommonADModel):
|
||||||
'target': target,
|
'target': target,
|
||||||
'label': rq.process.name,
|
'label': rq.process.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 去重边
|
|
||||||
unique_edges = {}
|
|
||||||
for edge in edges:
|
|
||||||
key = (edge['source'], edge['target'])
|
|
||||||
if key not in unique_edges:
|
|
||||||
unique_edges[key] = edge
|
|
||||||
# 将批次号排序
|
# 将批次号排序
|
||||||
nodes_qs = Material.objects.filter(id__in=nodes_set).order_by("process__sort", "create_time")
|
nodes_qs = Material.objects.filter(id__in=nodes_set).order_by("process__sort", "create_time")
|
||||||
|
|
||||||
# batch_to_id = {batch: idx for idx, batch in enumerate(nodes_list)}
|
|
||||||
# 构建节点数据,默认使用'rect'形状
|
# 构建节点数据,默认使用'rect'形状
|
||||||
nodes = [{
|
nodes = [{
|
||||||
'id': item.id,
|
'id': item.id,
|
||||||
|
@ -381,14 +518,7 @@ class Route(CommonADModel):
|
||||||
'shape': 'rect' # 可根据业务需求调整形状
|
'shape': 'rect' # 可根据业务需求调整形状
|
||||||
} for item in nodes_qs]
|
} for item in nodes_qs]
|
||||||
|
|
||||||
# 构建边数据
|
return {'nodes': nodes, 'edges': edges}
|
||||||
edges_converted = [{
|
|
||||||
'source': edge['source'],
|
|
||||||
'target': edge['target'],
|
|
||||||
'label': edge['label']
|
|
||||||
} for edge in unique_edges.values()]
|
|
||||||
|
|
||||||
return {'nodes': nodes, 'edges': edges_converted}
|
|
||||||
class RouteMat(BaseModel):
|
class RouteMat(BaseModel):
|
||||||
route = models.ForeignKey(Route, verbose_name='关联路线', on_delete=models.CASCADE, related_name="routemat_route")
|
route = models.ForeignKey(Route, verbose_name='关联路线', on_delete=models.CASCADE, related_name="routemat_route")
|
||||||
material = models.ForeignKey(Material, verbose_name='辅助物料', on_delete=models.CASCADE)
|
material = models.ForeignKey(Material, verbose_name='辅助物料', on_delete=models.CASCADE)
|
|
@ -146,6 +146,7 @@ def mgroup_run_change(mgroup: Mgroup, new_run: bool, last_timex: datetime, note:
|
||||||
|
|
||||||
def bind_routepack(ticket: Ticket, transition, new_ticket_data: dict):
|
def bind_routepack(ticket: Ticket, transition, new_ticket_data: dict):
|
||||||
routepack = RoutePack.objects.get(id=new_ticket_data['t_id'])
|
routepack = RoutePack.objects.get(id=new_ticket_data['t_id'])
|
||||||
|
routepack.get_gjson(need_update=True)
|
||||||
if routepack.ticket and routepack.ticket.id!=ticket.id:
|
if routepack.ticket and routepack.ticket.id!=ticket.id:
|
||||||
raise ParseError('重复创建工单')
|
raise ParseError('重复创建工单')
|
||||||
if not Route.objects.filter(routepack=routepack).exists():
|
if not Route.objects.filter(routepack=routepack).exists():
|
||||||
|
|
|
@ -275,6 +275,7 @@ class RoutePackViewSet(CustomModelViewSet):
|
||||||
ins.state = RoutePack.RP_S_CREATE
|
ins.state = RoutePack.RP_S_CREATE
|
||||||
elif ins.state == RoutePack.RP_S_CREATE:
|
elif ins.state == RoutePack.RP_S_CREATE:
|
||||||
if ins.ticket is not None:
|
if ins.ticket is not None:
|
||||||
|
ins.get_gjson(need_update=True)
|
||||||
ins.state = RoutePack.RP_S_CONFIRM
|
ins.state = RoutePack.RP_S_CONFIRM
|
||||||
else:
|
else:
|
||||||
raise ParseError("该路线未提交审核")
|
raise ParseError("该路线未提交审核")
|
||||||
|
@ -285,7 +286,31 @@ class RoutePackViewSet(CustomModelViewSet):
|
||||||
|
|
||||||
@action(methods=['get'], detail=True, perms_map={'get': '*'})
|
@action(methods=['get'], detail=True, perms_map={'get': '*'})
|
||||||
def dag(self, request, *args, **kwargs):
|
def dag(self, request, *args, **kwargs):
|
||||||
|
"""获取总图
|
||||||
|
|
||||||
|
获取总图
|
||||||
|
"""
|
||||||
return Response(Route.get_dag(rqs=Route.objects.filter(routepack=self.get_object())))
|
return Response(Route.get_dag(rqs=Route.objects.filter(routepack=self.get_object())))
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=True, perms_map={'get': '*'})
|
||||||
|
def dags(self, request, *args, **kwargs):
|
||||||
|
"""获取所有子图
|
||||||
|
|
||||||
|
获取所有子图
|
||||||
|
"""
|
||||||
|
ins = self.get_object()
|
||||||
|
return Response(ins.get_dags())
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=True, perms_map={'get': '*'})
|
||||||
|
def final_materials(self, request, *args, **kwargs):
|
||||||
|
"""获取最终产品
|
||||||
|
|
||||||
|
获取最终产品"""
|
||||||
|
ins:RoutePack = self.get_object()
|
||||||
|
matIds = ins.get_final_material_ids()
|
||||||
|
qs = Material.objects.filter(id__in=matIds)
|
||||||
|
res = [{"id": x.id, "name": str(x)} for x in qs]
|
||||||
|
return Response(res)
|
||||||
class RouteViewSet(CustomModelViewSet):
|
class RouteViewSet(CustomModelViewSet):
|
||||||
queryset = Route.objects.all()
|
queryset = Route.objects.all()
|
||||||
serializer_class = RouteSerializer
|
serializer_class = RouteSerializer
|
||||||
|
|
|
@ -9,6 +9,8 @@ from apps.utils.serializers import CustomModelSerializer
|
||||||
from apps.system.models import Dept
|
from apps.system.models import Dept
|
||||||
from apps.wpm.models import Mlog
|
from apps.wpm.models import Mlog
|
||||||
from apps.utils.constants import EXCLUDE_FIELDS_BASE
|
from apps.utils.constants import EXCLUDE_FIELDS_BASE
|
||||||
|
from apps.mtm.models import RoutePack, Material
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
|
||||||
class UtaskSerializer(CustomModelSerializer):
|
class UtaskSerializer(CustomModelSerializer):
|
||||||
|
@ -28,9 +30,16 @@ class UtaskSerializer(CustomModelSerializer):
|
||||||
'number': {"required": False, "allow_blank": True}
|
'number': {"required": False, "allow_blank": True}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if not validated_data.get('number', None):
|
if not validated_data.get('number', None):
|
||||||
validated_data["number"] = Utask.get_a_number()
|
validated_data["number"] = Utask.get_a_number()
|
||||||
|
routepack:RoutePack = validated_data.get("routepack", None)
|
||||||
|
material:Material = validated_data.get("material", None)
|
||||||
|
if routepack:
|
||||||
|
finMatIds = routepack.get_final_material_ids()
|
||||||
|
if (material and material.id not in finMatIds) or not material:
|
||||||
|
raise ParseError('请指定正确的产品')
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
@ -43,8 +52,6 @@ class UtaskSerializer(CustomModelSerializer):
|
||||||
attrs['count_day'] = math.ceil(attrs['count']/rela_days)
|
attrs['count_day'] = math.ceil(attrs['count']/rela_days)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ParseError('日均任务数计划失败')
|
raise ParseError('日均任务数计划失败')
|
||||||
if attrs.get('routepack', None):
|
|
||||||
attrs['material'] = attrs['routepack'].material
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
|
|
@ -49,6 +49,7 @@ class PmService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def cal_real_task_count(cls, count: int, rate_list):
|
def cal_real_task_count(cls, count: int, rate_list):
|
||||||
"""
|
"""
|
||||||
|
废弃
|
||||||
计算实际任务数
|
计算实际任务数
|
||||||
"""
|
"""
|
||||||
r_list = []
|
r_list = []
|
||||||
|
@ -167,15 +168,18 @@ class PmService:
|
||||||
else:
|
else:
|
||||||
# 获取产品的加工路线
|
# 获取产品的加工路线
|
||||||
if utask.routepack: # 指定工艺路线
|
if utask.routepack: # 指定工艺路线
|
||||||
rqs = Route.get_routes(routepack=utask.routepack)
|
gjson_item = utask.routepack.get_gjson(final_material_id=product.id)
|
||||||
|
if not gjson_item:
|
||||||
|
raise ParseError("缺少该产品的生产子图")
|
||||||
|
rqs = Route.get_routes(gjson_item["routes"])
|
||||||
else:
|
else:
|
||||||
rqs = Route.get_routes(material=product)
|
rqs = Route.get_routes(material=product)
|
||||||
if not rqs.exists():
|
if not rqs.exists():
|
||||||
raise ParseError('未配置工艺路线')
|
raise ParseError('未配置工艺路线')
|
||||||
|
|
||||||
# 通过出材率校正任务数, out_rate 默认为 100
|
# 通过出材率校正任务数, out_rate 默认为 100
|
||||||
Route.validate_dag(utask.material, rqs)
|
Route.validate_dag(product, rqs)
|
||||||
r_count_dict = cls.cal_x_task_count(utask.material.id, count, rqs)
|
r_count_dict = cls.cal_x_task_count(product.id, count, rqs)
|
||||||
|
|
||||||
# 创建小任务
|
# 创建小任务
|
||||||
for ind, (routeId, count) in enumerate(r_count_dict.items()):
|
for ind, (routeId, count) in enumerate(r_count_dict.items()):
|
||||||
|
|
|
@ -917,6 +917,10 @@ def get_batch_dag(batch_number: str):
|
||||||
edges = []
|
edges = []
|
||||||
prev_size = 0
|
prev_size = 0
|
||||||
|
|
||||||
|
r_dict = {
|
||||||
|
"split": "分",
|
||||||
|
"merge": "合"
|
||||||
|
}
|
||||||
while len(nodes_set) > prev_size:
|
while len(nodes_set) > prev_size:
|
||||||
prev_size = len(nodes_set)
|
prev_size = len(nodes_set)
|
||||||
# 查询所有与当前批次相关的记录(作为source或target)
|
# 查询所有与当前批次相关的记录(作为source或target)
|
||||||
|
@ -931,15 +935,16 @@ def get_batch_dag(batch_number: str):
|
||||||
edges.append({
|
edges.append({
|
||||||
'source': source,
|
'source': source,
|
||||||
'target': target,
|
'target': target,
|
||||||
'label': log.relation_type, # 使用relation_type作为边的标签
|
'label': r_dict.get(log.relation_type, ""), # 使用relation_type作为边的标签
|
||||||
})
|
})
|
||||||
|
|
||||||
# 去重边
|
# 去重边
|
||||||
unique_edges = {}
|
# unique_edges = {}
|
||||||
for edge in edges:
|
# for edge in edges:
|
||||||
key = (edge['source'], edge['target'])
|
# key = (edge['source'], edge['target'])
|
||||||
if key not in unique_edges:
|
# if key not in unique_edges:
|
||||||
unique_edges[key] = edge
|
# unique_edges[key] = edge
|
||||||
|
|
||||||
# 将批次号排序
|
# 将批次号排序
|
||||||
nodes_qs = BatchSt.objects.filter(id__in=nodes_set).order_by('id')
|
nodes_qs = BatchSt.objects.filter(id__in=nodes_set).order_by('id')
|
||||||
|
|
||||||
|
@ -952,11 +957,11 @@ def get_batch_dag(batch_number: str):
|
||||||
} for item in nodes_qs]
|
} for item in nodes_qs]
|
||||||
|
|
||||||
# 构建边数据
|
# 构建边数据
|
||||||
edges_converted = [{
|
# edges_converted = [{
|
||||||
'source': edge['source'],
|
# 'source': edge['source'],
|
||||||
'target': edge['target'],
|
# 'target': edge['target'],
|
||||||
'label': edge['label']
|
# 'label': edge['label']
|
||||||
} for edge in unique_edges.values()]
|
# } for edge in unique_edges.values()]
|
||||||
|
|
||||||
return {'nodes': nodes, 'edges': edges_converted}
|
return {'nodes': nodes, 'edges': edges}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue