From c002017a844f985518addb59451c4eab5d3b47a4 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 27 Mar 2025 12:40:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E8=89=BA=E8=B7=AF=E7=BA=BF?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AD=90=E5=9B=BE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mtm/migrations/0055_auto_20250327_1239.py | 24 +++ apps/mtm/models.py | 166 ++++++++++++++++-- apps/mtm/services.py | 1 + apps/mtm/views.py | 25 +++ apps/pm/serializers.py | 11 +- apps/pm/services.py | 10 +- apps/wpm/services.py | 29 +-- 7 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 apps/mtm/migrations/0055_auto_20250327_1239.py diff --git a/apps/mtm/migrations/0055_auto_20250327_1239.py b/apps/mtm/migrations/0055_auto_20250327_1239.py new file mode 100644 index 00000000..21971360 --- /dev/null +++ b/apps/mtm/migrations/0055_auto_20250327_1239.py @@ -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='产品'), + ), + ] diff --git a/apps/mtm/models.py b/apps/mtm/models.py index c825c912..15d21715 100644 --- a/apps/mtm/models.py +++ b/apps/mtm/models.py @@ -214,11 +214,154 @@ class RoutePack(CommonADModel): state = models.PositiveSmallIntegerField('状态', default=10, choices=RP_STATE) name = models.CharField('名称', max_length=100) 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='关联工单', 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) + 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): """ 加工路线 @@ -246,7 +389,7 @@ class Route(CommonADModel): parent = models.ForeignKey('self', verbose_name='上级路线', on_delete=models.CASCADE, null=True, blank=True) @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('未指定统计步骤') elif routepack: 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 @classmethod @@ -363,17 +508,9 @@ class Route(CommonADModel): 'target': target, '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") - # batch_to_id = {batch: idx for idx, batch in enumerate(nodes_list)} # 构建节点数据,默认使用'rect'形状 nodes = [{ 'id': item.id, @@ -381,14 +518,7 @@ class Route(CommonADModel): 'shape': 'rect' # 可根据业务需求调整形状 } for item in nodes_qs] - # 构建边数据 - edges_converted = [{ - 'source': edge['source'], - 'target': edge['target'], - 'label': edge['label'] - } for edge in unique_edges.values()] - - return {'nodes': nodes, 'edges': edges_converted} + return {'nodes': nodes, 'edges': edges} class RouteMat(BaseModel): route = models.ForeignKey(Route, verbose_name='关联路线', on_delete=models.CASCADE, related_name="routemat_route") material = models.ForeignKey(Material, verbose_name='辅助物料', on_delete=models.CASCADE) \ No newline at end of file diff --git a/apps/mtm/services.py b/apps/mtm/services.py index ed238e43..6ed55575 100644 --- a/apps/mtm/services.py +++ b/apps/mtm/services.py @@ -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): 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: raise ParseError('重复创建工单') if not Route.objects.filter(routepack=routepack).exists(): diff --git a/apps/mtm/views.py b/apps/mtm/views.py index 802c9c71..79ab02d5 100644 --- a/apps/mtm/views.py +++ b/apps/mtm/views.py @@ -275,6 +275,7 @@ class RoutePackViewSet(CustomModelViewSet): ins.state = RoutePack.RP_S_CREATE elif ins.state == RoutePack.RP_S_CREATE: if ins.ticket is not None: + ins.get_gjson(need_update=True) ins.state = RoutePack.RP_S_CONFIRM else: raise ParseError("该路线未提交审核") @@ -285,7 +286,31 @@ class RoutePackViewSet(CustomModelViewSet): @action(methods=['get'], detail=True, perms_map={'get': '*'}) def dag(self, request, *args, **kwargs): + """获取总图 + + 获取总图 + """ 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): queryset = Route.objects.all() serializer_class = RouteSerializer diff --git a/apps/pm/serializers.py b/apps/pm/serializers.py index 37b895d4..fb0b9b68 100644 --- a/apps/pm/serializers.py +++ b/apps/pm/serializers.py @@ -9,6 +9,8 @@ from apps.utils.serializers import CustomModelSerializer from apps.system.models import Dept from apps.wpm.models import Mlog from apps.utils.constants import EXCLUDE_FIELDS_BASE +from apps.mtm.models import RoutePack, Material +from django.db import transaction class UtaskSerializer(CustomModelSerializer): @@ -28,9 +30,16 @@ class UtaskSerializer(CustomModelSerializer): 'number': {"required": False, "allow_blank": True} } + @transaction.atomic def create(self, validated_data): if not validated_data.get('number', None): 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) def validate(self, attrs): @@ -43,8 +52,6 @@ class UtaskSerializer(CustomModelSerializer): attrs['count_day'] = math.ceil(attrs['count']/rela_days) except Exception: raise ParseError('日均任务数计划失败') - if attrs.get('routepack', None): - attrs['material'] = attrs['routepack'].material return attrs def update(self, instance, validated_data): diff --git a/apps/pm/services.py b/apps/pm/services.py index fd659fd7..27afe22a 100644 --- a/apps/pm/services.py +++ b/apps/pm/services.py @@ -49,6 +49,7 @@ class PmService: @classmethod def cal_real_task_count(cls, count: int, rate_list): """ + 废弃 计算实际任务数 """ r_list = [] @@ -167,15 +168,18 @@ class PmService: else: # 获取产品的加工路线 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: rqs = Route.get_routes(material=product) if not rqs.exists(): raise ParseError('未配置工艺路线') # 通过出材率校正任务数, out_rate 默认为 100 - Route.validate_dag(utask.material, rqs) - r_count_dict = cls.cal_x_task_count(utask.material.id, count, rqs) + Route.validate_dag(product, rqs) + r_count_dict = cls.cal_x_task_count(product.id, count, rqs) # 创建小任务 for ind, (routeId, count) in enumerate(r_count_dict.items()): diff --git a/apps/wpm/services.py b/apps/wpm/services.py index 787568f5..58ac1dfa 100644 --- a/apps/wpm/services.py +++ b/apps/wpm/services.py @@ -917,6 +917,10 @@ def get_batch_dag(batch_number: str): edges = [] prev_size = 0 + r_dict = { + "split": "分", + "merge": "合" + } while len(nodes_set) > prev_size: prev_size = len(nodes_set) # 查询所有与当前批次相关的记录(作为source或target) @@ -931,15 +935,16 @@ def get_batch_dag(batch_number: str): edges.append({ 'source': source, 'target': target, - 'label': log.relation_type, # 使用relation_type作为边的标签 + 'label': r_dict.get(log.relation_type, ""), # 使用relation_type作为边的标签 }) # 去重边 - unique_edges = {} - for edge in edges: - key = (edge['source'], edge['target']) - if key not in unique_edges: - unique_edges[key] = edge + # unique_edges = {} + # for edge in edges: + # key = (edge['source'], edge['target']) + # if key not in unique_edges: + # unique_edges[key] = edge + # 将批次号排序 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] # 构建边数据 - edges_converted = [{ - 'source': edge['source'], - 'target': edge['target'], - 'label': edge['label'] - } for edge in unique_edges.values()] + # edges_converted = [{ + # 'source': edge['source'], + # 'target': edge['target'], + # 'label': edge['label'] + # } for edge in unique_edges.values()] - return {'nodes': nodes, 'edges': edges_converted} + return {'nodes': nodes, 'edges': edges}