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:
caoqianming 2026-07-16 11:53:16 +08:00
parent 5c9bb530c7
commit 1e94027e17
6 changed files with 123 additions and 13 deletions

View File

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

View File

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

View File

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

39
apps/utils/orm.py Normal file
View File

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

View File

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

View File

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