feat: 工艺路线支持子图逻辑

This commit is contained in:
caoqianming 2025-03-27 12:40:09 +08:00
parent a31faa0cda
commit c002017a84
7 changed files with 231 additions and 35 deletions

View File

@ -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='产品'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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