From 1e94027e17aa5ce544785d775838b90c44d738d6 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 16 Jul 2026 11:53:16 +0800 Subject: [PATCH] =?UTF-8?q?perf:=E9=94=80=E5=94=AE=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E9=9A=90=E8=97=8F=E5=8F=AF=E5=8F=91=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E4=B8=BA0=E7=9A=84=E6=89=B9=E6=AC=A1,=E5=B9=B6?= =?UTF-8?q?=E7=94=A8=E5=AD=90=E6=9F=A5=E8=AF=A2=E6=B6=88=E9=99=A4=E5=9C=A8?= =?UTF-8?q?=E9=80=94=E9=87=8FN+1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inm:MaterialBatchFilter 新增 count_canmio__gt 过滤,销售发货(sale_out)只展示可发>0的批次 - inm/wpm:count_mioing/count_working/count_handovering 改用关联子查询单次注解, 消除列表逐行属性的N+1(纯访问101→1),并保留decimal精度(修正原IntegerField截断 导致可发/可用量偏大的隐患) - 新增 apps/utils/orm.py:inflight_sum(多聚合用子查询避免JOIN翻倍) + restql_wants (?query未取这些字段时跳过子查询) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/inm/filters.py | 8 +++++++- apps/inm/serializers.py | 21 +++++++++++++++++---- apps/inm/views.py | 15 +++++++++++++++ apps/utils/orm.py | 39 +++++++++++++++++++++++++++++++++++++++ apps/wpm/serializers.py | 36 +++++++++++++++++++++++++++++------- apps/wpm/views.py | 17 ++++++++++++++++- 6 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 apps/utils/orm.py diff --git a/apps/inm/filters.py b/apps/inm/filters.py index 977fcc1e..a7455474 100644 --- a/apps/inm/filters.py +++ b/apps/inm/filters.py @@ -1,8 +1,10 @@ from django_filters import rest_framework as filters from apps.inm.models import MaterialBatch, MIO -from django.db.models import Q, Subquery, OuterRef +from django.db.models import Q, Subquery, OuterRef, F class MaterialBatchFilter(filters.FilterSet): + count_canmio__gt = filters.NumberFilter( + method='filter_count_canmio__gt', label='可发数量大于') class Meta: model = MaterialBatch @@ -17,6 +19,10 @@ class MaterialBatchFilter(filters.FilterSet): "batch": ["exact"] } + def filter_count_canmio__gt(self, queryset, name, value): + # 可发数量 = 存量 - 正在出入库数量; count_mioing_anno 由 MaterialBatchViewSet 的 queryset 注解 + return queryset.filter(count__gt=F('count_mioing_anno') + value) + class MioFilter(filters.FilterSet): materials__type = filters.CharFilter( diff --git a/apps/inm/serializers.py b/apps/inm/serializers.py index 5f832f01..5445dd6a 100644 --- a/apps/inm/serializers.py +++ b/apps/inm/serializers.py @@ -49,17 +49,30 @@ class MaterialBatchSerializer(CustomModelSerializer): source='supplier', read_only=True) material_ = MaterialSerializer(source='material', read_only=True) defect_name = serializers.CharField(source="defect.name", read_only=True) - count_mioing = serializers.IntegerField(read_only=True, label='正在出入库数量') + count_mioing = serializers.SerializerMethodField(label='正在出入库数量') class Meta: model = MaterialBatch fields = '__all__' read_only_fields = EXCLUDE_FIELDS_BASE - + + def get_count_mioing(self, instance): + # 列表接口的 queryset 已注解 count_mioing_anno(单次聚合); 嵌套等无注解场景回退模型属性 + # 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可发量偏大) + return instance.count_mioing_anno if hasattr(instance, 'count_mioing_anno') else instance.count_mioing + def to_representation(self, instance): ret = super().to_representation(instance) - if 'count' in ret and 'count_mioing' in ret: - ret['count_canmio'] = str(Decimal(ret['count']) - Decimal(ret['count_mioing'])) + if 'count' in ret: + # 优先用已输出字段, 否则用注解值; 都没有(调用方用 query 关闭了该组字段)则不算, 避免误触发 N+1 + if 'count_mioing' in ret: + mioing = ret['count_mioing'] + elif hasattr(instance, 'count_mioing_anno'): + mioing = instance.count_mioing_anno + else: + mioing = None + if mioing is not None: + ret['count_canmio'] = str(Decimal(ret['count']) - Decimal(mioing)) return ret diff --git a/apps/inm/views.py b/apps/inm/views.py index b0926bc6..07f9b513 100644 --- a/apps/inm/views.py +++ b/apps/inm/views.py @@ -21,6 +21,7 @@ from apps.inm.services import InmService from apps.inm.services_daoru import daoru_mb, daoru_mioitem_test, daoru_mioitems from apps.utils.mixins import (BulkCreateModelMixin, BulkDestroyModelMixin, BulkUpdateModelMixin, CustomListModelMixin) +from apps.utils.orm import inflight_sum, restql_wants from apps.utils.permission import has_perm from .filters import MaterialBatchFilter, MioFilter from apps.qm.serializers import FtestProcessSerializer @@ -65,6 +66,20 @@ class MaterialBatchViewSet(ListModelMixin, CustomGenericViewSet): 'material__model', 'material__specification', 'batch'] ordering = ['-update_time'] + def get_queryset(self): + queryset = super().get_queryset() + # 可发数量(count_canmio = 存量 - 正在出入库数量)相关字段/过滤按需注解在途量, + # 单次聚合替代逐行属性 N+1; 调用方用 ?query={...} 不取这些字段时则跳过子查询 + need = (restql_wants(self.request, ('count_mioing', 'count_canmio')) + or 'count_canmio__gt' in self.request.query_params) + if need: + queryset = queryset.annotate( + count_mioing_anno=inflight_sum( + MIOItem, 'mb', 'mio__submit_time__isnull', 'count', + max_digits=12, decimal_places=3), + ) + return queryset + @action(methods=['post'], detail=False, serializer_class=serializers.Serializer, perms_map={'post': 'materialbatch.daoru'}) @transaction.atomic def daoru(self, request, *args, **kwargs): diff --git a/apps/utils/orm.py b/apps/utils/orm.py new file mode 100644 index 00000000..c76355fc --- /dev/null +++ b/apps/utils/orm.py @@ -0,0 +1,39 @@ +"""ORM 通用辅助""" +from django.db.models import Subquery, OuterRef, Sum, Value, DecimalField +from django.db.models.functions import Coalesce + + +def inflight_sum(model, fk, submit_isnull_path, amount, *, max_digits=12, decimal_places=1): + """未提交单据的"在途"聚合量(存量之外正在出入库/在制/在途交接的数量)。 + + 用关联标量子查询实现, 不与主表 JOIN, 因此可在同一个 queryset 上安全叠加 + 多个不同多值关系的聚合而不会因笛卡尔积翻倍(Django 多聚合经典陷阱)。 + + :param model: 明细模型, 如 MIOItem / Mlogb / Handoverb + :param fk: 明细指向主表的外键名, 如 'mb' / 'wm_in' / 'wm' + :param submit_isnull_path: 判定"未提交"的查询路径, 如 'mio__submit_time__isnull' + :param amount: 参与求和的数量字段, 如 'count' / 'count_use' + """ + return Coalesce( + Subquery( + model.objects + .filter(**{fk: OuterRef('pk'), submit_isnull_path: True}) + .order_by() # 清掉默认排序, 否则污染 GROUP BY + .values(fk) # 按外键分组 -> 每个主键一行 + .annotate(_s=Sum(amount)) + .values('_s') # 取出标量 + ), + Value(0, output_field=DecimalField(max_digits=max_digits, decimal_places=decimal_places)), + ) + + +def restql_wants(request, field_names): + """django-restql 稀疏字段判断: 用于"不需要的字段就不做昂贵注解"。 + + 未带 query 参数 -> 视为需要全部字段(保持默认行为); + 带了 query -> 仅当其中点名了任一 field_names 时才认为需要。 + """ + query = request.query_params.get('query') if request else None + if not query: + return True + return any(name in query for name in field_names) diff --git a/apps/wpm/serializers.py b/apps/wpm/serializers.py index 64a289ad..f3c354ae 100644 --- a/apps/wpm/serializers.py +++ b/apps/wpm/serializers.py @@ -189,22 +189,44 @@ class WMaterialSerializer(CustomModelSerializer): material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True) notok_sign_name = serializers.SerializerMethodField() defect_name = serializers.CharField(source="defect.name", read_only=True) - count_working = serializers.IntegerField(read_only=True, label='在制数量') - count_handovering = serializers.IntegerField(read_only=True, label='正在交送的数量') + count_working = serializers.SerializerMethodField(label='在制数量') + count_handovering = serializers.SerializerMethodField(label='正在交送的数量') def get_notok_sign_name(self, obj): return getattr(NotOkOption, obj.notok_sign, NotOkOption.qt).label if obj.notok_sign else None + def get_count_working(self, obj): + # 列表接口 queryset 已注解(单次聚合); 嵌套等无注解场景回退模型属性 + # 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可用/可交接量偏大) + return obj.count_working_anno if hasattr(obj, 'count_working_anno') else obj.count_working + + def get_count_handovering(self, obj): + return obj.count_handovering_anno if hasattr(obj, 'count_handovering_anno') else obj.count_handovering + class Meta: model = WMaterial fields = '__all__' - + def to_representation(self, instance): ret = super().to_representation(instance) - if 'count' in ret and 'count_working' in ret: - ret['count_cando'] = str(Decimal(ret['count']) - Decimal(ret['count_working'])) - if 'count' in ret and 'count_handovering' in ret: - ret['count_canhandover'] = str(Decimal(ret['count']) - Decimal(ret['count_handovering'])) + if 'count' in ret: + # 优先用已输出字段, 否则用注解值; 都没有(调用方用 query 关闭了该组字段)则不算, 避免误触发 N+1 + if 'count_working' in ret: + working = ret['count_working'] + elif hasattr(instance, 'count_working_anno'): + working = instance.count_working_anno + else: + working = None + if working is not None: + ret['count_cando'] = str(Decimal(ret['count']) - Decimal(working)) + if 'count_handovering' in ret: + handovering = ret['count_handovering'] + elif hasattr(instance, 'count_handovering_anno'): + handovering = instance.count_handovering_anno + else: + handovering = None + if handovering is not None: + ret['count_canhandover'] = str(Decimal(ret['count']) - Decimal(handovering)) return ret class WMaterialCreateSerializer(CustomModelSerializer): diff --git a/apps/wpm/views.py b/apps/wpm/views.py index d5a5d2a1..dc2e5809 100644 --- a/apps/wpm/views.py +++ b/apps/wpm/views.py @@ -13,6 +13,7 @@ from apps.mtm.models import Material, Process, Route, Mgroup, RoutePack, RouteMa from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet from rest_framework.mixins import DestroyModelMixin from apps.utils.mixins import CustomListModelMixin, CustomCreateModelMixin, BulkCreateModelMixin, ComplexQueryMixin, BulkDestroyModelMixin, BulkUpdateModelMixin +from apps.utils.orm import inflight_sum, restql_wants from .filters import StLogFilter, SfLogFilter, WMaterialFilter, MlogFilter, HandoverFilter, MlogbFilter, BatchStFilter, MlogbwFilter from .models import SfLog, SfLogExp, StLog, WMaterial, Mlog, Handover, Mlogb, Mlogbw, AttLog, OtherLog, Fmlog, BatchSt, MlogbDefect, MlogUser, BatchLog, Handoverb @@ -183,7 +184,21 @@ class WMaterialViewSet(CustomCreateModelMixin, DestroyModelMixin, CustomListMode def get_queryset(self): if self.request.query_params.get("count_all"): self.queryset = WMaterial.objects.all() - return super().get_queryset() + queryset = super().get_queryset() + # count_cando/count_canhandover 相关字段按需注解在制/在途交接量: + # 两个不同多值关系分别用关联子查询, 避免同一 queryset 多聚合 JOIN 翻倍; + # 单次聚合替代逐行属性 N+1; 调用方 ?query={...} 不取这些字段时跳过子查询 + if restql_wants(self.request, ('count_working', 'count_handovering', + 'count_cando', 'count_canhandover')): + queryset = queryset.annotate( + count_working_anno=inflight_sum( + Mlogb, 'wm_in', 'mlog__submit_time__isnull', 'count_use', + max_digits=11, decimal_places=1), + count_handovering_anno=inflight_sum( + Handoverb, 'wm', 'handover__submit_time__isnull', 'count', + max_digits=11, decimal_places=1), + ) + return queryset def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset)