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 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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue