""" 库存变动记录列表查询视图 """ from rest_framework import status, views from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from datetime import datetime, timedelta import logging from stock import models as stock_models from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes from .mixins import StockChangeViewMixin logger = logging.getLogger(__name__) class ListStockChangesView(StockChangeViewMixin, views.APIView): """获取库存变动记录列表""" permission_classes = [IsAuthenticated] @extend_schema( tags=['读取出入库列表'], parameters=[ OpenApiParameter(name='start', type=OpenApiTypes.DATE, description='开始日期 (YYYY-MM-DD),默认为昨天'), OpenApiParameter(name='end', type=OpenApiTypes.DATE, description='结束日期 (YYYY-MM-DD),默认为昨天'), OpenApiParameter(name='type', type=OpenApiTypes.INT, description='变动类型 (1=入库, 2=出库)'), OpenApiParameter(name='source_type', type=OpenApiTypes.INT, description='来源类型 (如采购=1)'), OpenApiParameter(name='source_id', type=OpenApiTypes.INT, description='来源业务单据ID'), OpenApiParameter(name='source_id_isnull', type=OpenApiTypes.BOOL, description='来源业务单据ID是否为空'), OpenApiParameter(name='warehouse', type=OpenApiTypes.INT, description='仓库ID'), OpenApiParameter(name='product', type=OpenApiTypes.INT, description='产品ID'), OpenApiParameter(name='is_finished', type=OpenApiTypes.BOOL, description='是否已完成'), OpenApiParameter(name='include_details', type=OpenApiTypes.BOOL, description='是否包含明细,默认true'), ], responses={200: dict}, summary="获取库存变动记录列表", description="按时间范围查询库存变动记录,支持多种过滤条件" ) def get(self, request): """ 获取库存变动记录列表(按时间范围查询) 查询参数: - start: 开始日期 (YYYY-MM-DD),可选,默认为昨天 - end: 结束日期 (YYYY-MM-DD),可选,默认为昨天 - type: 变动类型 (1=入库, 2=出库),可选 - source_type: 来源类型(可选) - source_id: 来源业务单据ID(可选) - source_id_isnull: 来源业务单据ID是否为空(可选,true/false) - warehouse: 仓库ID,可选 - product: 产品ID,可选 - is_finished: 是否已完成 (true/false),可选 - include_details: 是否包含明细记录 (true/false),可选,默认为 true """ # 检查员工权限 if not self.check_employee_permission(request): return self.permission_error_response('无权限访问') merchant_id = self.get_merchant_id(request) try: # 获取查询参数 start_date_str = request.GET.get('start') end_date_str = request.GET.get('end') # 默认查询昨天的数据 if not start_date_str and not end_date_str: yesterday = datetime.now().date() - timedelta(days=1) start_date = yesterday end_date = yesterday else: # 解析日期参数 try: if start_date_str: start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() else: start_date = datetime.now().date() - timedelta(days=1) if end_date_str: end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() else: end_date = start_date except ValueError: return Response({ 'error': '日期格式错误,请使用 YYYY-MM-DD 格式' }, status=status.HTTP_400_BAD_REQUEST) # 验证日期范围 if start_date > end_date: return Response({ 'error': '开始日期不能大于结束日期' }, status=status.HTTP_400_BAD_REQUEST) # 构建查询 queryset = stock_models.StockChangeRecord.objects.filter( merchant_id=merchant_id, created_at__date__gte=start_date, created_at__date__lte=end_date ).select_related('warehouse', 'created_by') # 可选过滤条件 change_type = request.GET.get('type') if change_type: try: queryset = queryset.filter(type=int(change_type)) except ValueError: return Response({ 'error': 'type 参数必须为整数' }, status=status.HTTP_400_BAD_REQUEST) source_type = request.GET.get('source_type') if source_type: try: queryset = queryset.filter(source_type=int(source_type)) except ValueError: return Response({ 'error': 'source_type 参数必须为整数' }, status=status.HTTP_400_BAD_REQUEST) source_id = request.GET.get('source_id') if source_id: try: queryset = queryset.filter(source_id=int(source_id)) except ValueError: return Response({ 'error': 'source_id 参数必须为整数' }, status=status.HTTP_400_BAD_REQUEST) else: source_id_isnull = request.GET.get('source_id_isnull') if source_id_isnull is not None: if source_id_isnull.lower() == 'true': queryset = queryset.filter(source_id__isnull=True) elif source_id_isnull.lower() == 'false': queryset = queryset.filter(source_id__isnull=False) warehouse_id = request.GET.get('warehouse') if warehouse_id: try: warehouse_id = int(warehouse_id) # 验证仓库是否对用户可见 if not self.validate_warehouse_visibility(warehouse_id, request): return Response({ 'error': f'仓库ID {warehouse_id} 对当前用户不可见' }, status=status.HTTP_403_FORBIDDEN) queryset = queryset.filter(warehouse_id=warehouse_id) except ValueError: return Response({ 'error': 'warehouse 参数必须为整数' }, status=status.HTTP_400_BAD_REQUEST) # 产品过滤参数 product_id = request.GET.get('product') if product_id: try: product_id = int(product_id) # 验证产品是否对用户可见 if not self.validate_product_visibility(product_id, request): return Response({ 'error': f'产品ID {product_id} 对当前用户不可见' }, status=status.HTTP_403_FORBIDDEN) # 只返回包含该产品的库存变动记录 queryset = queryset.filter(details__product_id=product_id).distinct() except ValueError: return Response({ 'error': 'product 参数必须为整数' }, status=status.HTTP_400_BAD_REQUEST) is_finished = request.GET.get('is_finished') if is_finished is not None: if is_finished.lower() == 'true': queryset = queryset.filter(is_finished=True) elif is_finished.lower() == 'false': queryset = queryset.filter(is_finished=False) # 是否包含明细记录,默认为 true include_details_str = request.GET.get('include_details', 'true') include_details = include_details_str.lower() != 'false' # 过滤掉用户不可见的仓库和产品的记录 visible_records = [] for record in queryset: # 检查仓库可见性 if not self.validate_warehouse_visibility(record.warehouse_id, request): continue # 检查该记录是否包含至少一个用户可见的产品 has_visible_product = False record_details = stock_models.StockChangeDetail.objects.filter( stock_change_record=record ).select_related('product') for detail in record_details: if self.validate_product_visibility(detail.product_id, request): has_visible_product = True break # 只有当记录包含至少一个可见产品时才添加 if has_visible_product: visible_records.append(record) # 构建响应数据 results = [] for record in visible_records: # 获取该记录的所有明细 all_details = stock_models.StockChangeDetail.objects.filter( stock_change_record=record ).select_related('product') # 只统计和返回用户可见的产品明细 visible_details = self.filter_visible_details(all_details, request) total_quantity = sum(float(detail.quantity) for detail in visible_details) record_data = self.build_record_data(record) record_data['details_count'] = len(visible_details) record_data['total_quantity'] = total_quantity # 如果需要包含明细,则添加明细数据 if include_details: record_data['details'] = [self.build_detail_data(detail) for detail in visible_details] results.append(record_data) response_data = { 'count': len(results), 'results': results, 'date_range': { 'start': start_date.strftime('%Y-%m-%d'), 'end': end_date.strftime('%Y-%m-%d') } } logger.info( f"用户 {request.user.username} 查询库存变动记录列表," f"时间范围: {start_date} 到 {end_date},共 {len(results)} 条" ) return Response(response_data, status=status.HTTP_200_OK) except AttributeError as e: logger.error(f"用户 {request.user.username} 无员工信息: {str(e)}", exc_info=True) return self.permission_error_response('用户无员工信息,无权访问') except Exception as e: logger.error(f"查询库存变动记录列表失败: {str(e)}", exc_info=True) return Response({ 'error': '查询库存变动记录列表失败', 'message': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) class ListStockChangeDetailsView(StockChangeViewMixin, views.APIView): """获取库存变动明细列表""" permission_classes = [IsAuthenticated] @extend_schema( tags=['读取库存明细'], parameters=[ OpenApiParameter(name='warehouse_id', type=OpenApiTypes.INT, required=True, description='仓库ID'), OpenApiParameter(name='product_id', type=OpenApiTypes.INT, required=True, description='产品ID'), OpenApiParameter(name='direction', type=OpenApiTypes.STR, required=False, description='变动方向 (inbound=入库, outbound=出库),默认为inbound'), ], responses={200: dict}, summary="获取库存变动明细列表", description="根据仓库和产品查询库存变动明细,不查询StockChangeRecord" ) def get(self, request): """ 获取库存变动明细列表(根据仓库和产品查询) 查询参数: - warehouse_id: 仓库ID,必填 - product_id: 产品ID,必填 - direction: 变动方向 (inbound=入库, outbound=出库),可选,默认为inbound """ # 检查员工权限 if not self.check_employee_permission(request): return self.permission_error_response('无权限访问') merchant_id = self.get_merchant_id(request) try: # 获取查询参数 warehouse_id_str = request.GET.get('warehouse_id') product_id_str = request.GET.get('product_id') direction = request.GET.get('direction', 'inbound') # 默认为入库 # 验证必填参数 if not warehouse_id_str: return Response({ 'error': '缺少必要参数', 'message': 'warehouse_id 参数为必填' }, status=status.HTTP_400_BAD_REQUEST) if not product_id_str: return Response({ 'error': '缺少必要参数', 'message': 'product_id 参数为必填' }, status=status.HTTP_400_BAD_REQUEST) # 验证direction参数 if direction not in ['inbound', 'outbound']: return Response({ 'error': '参数格式错误', 'message': 'direction 参数必须为 inbound 或 outbound' }, status=status.HTTP_400_BAD_REQUEST) # 解析参数 try: warehouse_id = int(warehouse_id_str) product_id = int(product_id_str) except ValueError: return Response({ 'error': '参数格式错误', 'message': 'warehouse_id 和 product_id 必须为整数' }, status=status.HTTP_400_BAD_REQUEST) # 验证仓库是否对用户可见 if not self.validate_warehouse_visibility(warehouse_id, request): return Response({ 'error': f'仓库ID {warehouse_id} 对当前用户不可见' }, status=status.HTTP_403_FORBIDDEN) # 验证产品是否对用户可见 if not self.validate_product_visibility(product_id, request): return Response({ 'error': f'产品ID {product_id} 对当前用户不可见' }, status=status.HTTP_403_FORBIDDEN) # 根据direction参数确定变动类型 if direction == 'inbound': change_type = stock_models.StockChangeTypeEnum.ADD # 入库 else: # outbound change_type = stock_models.StockChangeTypeEnum.REMOVE # 出库 # 查询库存变动明细,仅查询未被消耗的明细(consumed_by_detail为空) active_freeze_statuses = [ status for status in stock_models.StockFreezeStatusEnum.values if status not in { stock_models.StockFreezeStatusEnum.CANCELLED, stock_models.StockFreezeStatusEnum.COMPLETED, } ] details = stock_models.StockChangeDetail.objects.filter( stock_change_record__merchant_id=merchant_id, stock_change_record__warehouse_id=warehouse_id, stock_change_record__type=change_type, product_id=product_id, consumed_by_detail__isnull=True # 仅查询未被消耗的明细 ).exclude( stock_freezes__status__in=active_freeze_statuses ).select_related( 'stock_change_record', 'product' ).order_by('-stock_change_record__created_at') # 构建响应数据 results = [] for detail in details: detail_data = self.build_detail_data(detail) # 添加记录的额外信息 detail_data['record_type'] = detail.stock_change_record.type detail_data['record_type_display'] = detail.stock_change_record.get_type_display() detail_data['source_type'] = detail.stock_change_record.source_type detail_data['source_type_display'] = detail.stock_change_record.get_source_type_display() detail_data['record_created_at'] = detail.stock_change_record.created_at detail_data['record_created_by'] = detail.stock_change_record.created_by.username results.append(detail_data) response_data = { 'count': len(results), 'results': results, 'filters': { 'warehouse_id': warehouse_id, 'product_id': product_id, 'direction': direction } } logger.info( f"用户 {request.user.username} 查询库存变动明细," f"仓库ID: {warehouse_id},产品ID: {product_id},共 {len(results)} 条" ) return Response(response_data, status=status.HTTP_200_OK) except AttributeError as e: logger.error(f"用户 {request.user.username} 无员工信息: {str(e)}", exc_info=True) return self.permission_error_response('用户无员工信息,无权访问') except Exception as e: logger.error(f"查询库存变动明细失败: {str(e)}", exc_info=True) return Response({ 'error': '查询库存变动明细失败', 'message': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)