perf:销售发货批次隐藏可发数量为0的批次,并用子查询消除在途量N+1
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
5c9bb530c7
commit
1e94027e17
|
|
@ -1,8 +1,10 @@
|
||||||
from django_filters import rest_framework as filters
|
from django_filters import rest_framework as filters
|
||||||
from apps.inm.models import MaterialBatch, MIO
|
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):
|
class MaterialBatchFilter(filters.FilterSet):
|
||||||
|
count_canmio__gt = filters.NumberFilter(
|
||||||
|
method='filter_count_canmio__gt', label='可发数量大于')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MaterialBatch
|
model = MaterialBatch
|
||||||
|
|
@ -17,6 +19,10 @@ class MaterialBatchFilter(filters.FilterSet):
|
||||||
"batch": ["exact"]
|
"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):
|
class MioFilter(filters.FilterSet):
|
||||||
materials__type = filters.CharFilter(
|
materials__type = filters.CharFilter(
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,30 @@ class MaterialBatchSerializer(CustomModelSerializer):
|
||||||
source='supplier', read_only=True)
|
source='supplier', read_only=True)
|
||||||
material_ = MaterialSerializer(source='material', read_only=True)
|
material_ = MaterialSerializer(source='material', read_only=True)
|
||||||
defect_name = serializers.CharField(source="defect.name", 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:
|
class Meta:
|
||||||
model = MaterialBatch
|
model = MaterialBatch
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
read_only_fields = EXCLUDE_FIELDS_BASE
|
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):
|
def to_representation(self, instance):
|
||||||
ret = super().to_representation(instance)
|
ret = super().to_representation(instance)
|
||||||
if 'count' in ret and 'count_mioing' in ret:
|
if 'count' in ret:
|
||||||
ret['count_canmio'] = str(Decimal(ret['count']) - Decimal(ret['count_mioing']))
|
# 优先用已输出字段, 否则用注解值; 都没有(调用方用 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
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.inm.services_daoru import daoru_mb, daoru_mioitem_test, daoru_mioitems
|
||||||
from apps.utils.mixins import (BulkCreateModelMixin, BulkDestroyModelMixin, BulkUpdateModelMixin,
|
from apps.utils.mixins import (BulkCreateModelMixin, BulkDestroyModelMixin, BulkUpdateModelMixin,
|
||||||
CustomListModelMixin)
|
CustomListModelMixin)
|
||||||
|
from apps.utils.orm import inflight_sum, restql_wants
|
||||||
from apps.utils.permission import has_perm
|
from apps.utils.permission import has_perm
|
||||||
from .filters import MaterialBatchFilter, MioFilter
|
from .filters import MaterialBatchFilter, MioFilter
|
||||||
from apps.qm.serializers import FtestProcessSerializer
|
from apps.qm.serializers import FtestProcessSerializer
|
||||||
|
|
@ -65,6 +66,20 @@ class MaterialBatchViewSet(ListModelMixin, CustomGenericViewSet):
|
||||||
'material__model', 'material__specification', 'batch']
|
'material__model', 'material__specification', 'batch']
|
||||||
ordering = ['-update_time']
|
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'})
|
@action(methods=['post'], detail=False, serializer_class=serializers.Serializer, perms_map={'post': 'materialbatch.daoru'})
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def daoru(self, request, *args, **kwargs):
|
def daoru(self, request, *args, **kwargs):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -189,22 +189,44 @@ class WMaterialSerializer(CustomModelSerializer):
|
||||||
material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True)
|
material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True)
|
||||||
notok_sign_name = serializers.SerializerMethodField()
|
notok_sign_name = serializers.SerializerMethodField()
|
||||||
defect_name = serializers.CharField(source="defect.name", read_only=True)
|
defect_name = serializers.CharField(source="defect.name", read_only=True)
|
||||||
count_working = serializers.IntegerField(read_only=True, label='在制数量')
|
count_working = serializers.SerializerMethodField(label='在制数量')
|
||||||
count_handovering = serializers.IntegerField(read_only=True, label='正在交送的数量')
|
count_handovering = serializers.SerializerMethodField(label='正在交送的数量')
|
||||||
|
|
||||||
def get_notok_sign_name(self, obj):
|
def get_notok_sign_name(self, obj):
|
||||||
return getattr(NotOkOption, obj.notok_sign, NotOkOption.qt).label if obj.notok_sign else None
|
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:
|
class Meta:
|
||||||
model = WMaterial
|
model = WMaterial
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
ret = super().to_representation(instance)
|
ret = super().to_representation(instance)
|
||||||
if 'count' in ret and 'count_working' in ret:
|
if 'count' in ret:
|
||||||
ret['count_cando'] = str(Decimal(ret['count']) - Decimal(ret['count_working']))
|
# 优先用已输出字段, 否则用注解值; 都没有(调用方用 query 关闭了该组字段)则不算, 避免误触发 N+1
|
||||||
if 'count' in ret and 'count_handovering' in ret:
|
if 'count_working' in ret:
|
||||||
ret['count_canhandover'] = str(Decimal(ret['count']) - Decimal(ret['count_handovering']))
|
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
|
return ret
|
||||||
|
|
||||||
class WMaterialCreateSerializer(CustomModelSerializer):
|
class WMaterialCreateSerializer(CustomModelSerializer):
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from apps.mtm.models import Material, Process, Route, Mgroup, RoutePack, RouteMa
|
||||||
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
|
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
|
||||||
from rest_framework.mixins import DestroyModelMixin
|
from rest_framework.mixins import DestroyModelMixin
|
||||||
from apps.utils.mixins import CustomListModelMixin, CustomCreateModelMixin, BulkCreateModelMixin, ComplexQueryMixin, BulkDestroyModelMixin, BulkUpdateModelMixin
|
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 .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
|
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):
|
def get_queryset(self):
|
||||||
if self.request.query_params.get("count_all"):
|
if self.request.query_params.get("count_all"):
|
||||||
self.queryset = WMaterial.objects.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):
|
def filter_queryset(self, queryset):
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue