diff --git a/.env b/.env index cdc2a77..a857286 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ SECRET_KEY=testing_$weak_secret_is_allowd DEBUG=True ALLOWED_HOSTS=*, -DB_HOST=postgres +DB_HOST=localhost DB_PORT=5432 DB_NAME=flower DB_USER=postgres diff --git a/api_v1/urls.py b/api_v1/urls.py index aeaac2b..41e88ee 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ # 库存变动相关API path('stock-snapshots/', StockSnapshotListView.as_view(), name='list_stock_snapshots'), path('stock-changes/', stock_change_views.list_stock_changes, name='list_stock_changes'), + path('stock-change-details/', stock_change_views.list_stock_change_details, name='list_stock_change_details'), path('stock-change/', stock_change_views.create_full_stock_change, name='create_full_stock_change'), path('stock-change/relaxed/', stock_change_views.create_relaxed_stock_change, name='create_relaxed_stock_change'), path('stock-change/restrict/', stock_change_views.create_restrict_stock_change, name='create_restrict_stock_change'), diff --git a/api_v1/views/stock_change_views/__init__.py b/api_v1/views/stock_change_views/__init__.py index 6afa8fe..ba5cfa2 100644 --- a/api_v1/views/stock_change_views/__init__.py +++ b/api_v1/views/stock_change_views/__init__.py @@ -10,7 +10,7 @@ from .create import ( CreateStockChangeRelaxedView, CreateStockChangeRestrictView, ) -from .list import ListStockChangesView +from .list import ListStockChangesView, ListStockChangeDetailsView from .detail import GetStockChangeView from .settings import SetMerchantAutoCompleteView @@ -19,6 +19,7 @@ create_full_stock_change = CreateStockChangeView.as_view() create_relaxed_stock_change = CreateStockChangeRelaxedView.as_view() create_restrict_stock_change = CreateStockChangeRestrictView.as_view() list_stock_changes = ListStockChangesView.as_view() +list_stock_change_details = ListStockChangeDetailsView.as_view() get_stock_change = GetStockChangeView.as_view() set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view() @@ -28,12 +29,14 @@ __all__ = [ 'CreateStockChangeRelaxedView', 'CreateStockChangeRestrictView', 'ListStockChangesView', + 'ListStockChangeDetailsView', 'GetStockChangeView', 'SetMerchantAutoCompleteView', 'create_full_stock_change', 'create_relaxed_stock_change', 'create_restrict_stock_change', 'list_stock_changes', + 'list_stock_change_details', 'get_stock_change', 'set_merchant_auto_complete_stock_change', ] diff --git a/api_v1/views/stock_change_views/list.py b/api_v1/views/stock_change_views/list.py index b274180..3dd9a2a 100644 --- a/api_v1/views/stock_change_views/list.py +++ b/api_v1/views/stock_change_views/list.py @@ -219,3 +219,142 @@ class ListStockChangesView(StockChangeViewMixin, views.APIView): '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为空) + 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 # 仅查询未被消耗的明细 + ).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) diff --git a/api_v1/views/stock_change_views/test_stock_change_api.py b/api_v1/views/stock_change_views/test_stock_change_api.py index 040438c..dba7a0b 100644 --- a/api_v1/views/stock_change_views/test_stock_change_api.py +++ b/api_v1/views/stock_change_views/test_stock_change_api.py @@ -106,3 +106,292 @@ class StockChangeRestrictAPITestCase(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('consume_with', str(response.data)) + +class ListStockChangeDetailsAPITestCase(TestCase): + """测试库存变动明细查询 API""" + + def setUp(self): + self.client = APIClient() + + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.category = basic_models.ProductCategory.objects.create( + merchant=self.merchant, + name='布料', + product_prefix='FAB', + ) + self.product = basic_models.Product.objects.create( + merchant=self.merchant, + category=self.category, + name='测试布料', + human_id='FAB-001', + unit=basic_models.ProductUnitEnum.METER, + ) + self.warehouse = basic_models.WareHouse.objects.create( + merchant=self.merchant, + name='测试仓库', + mode=basic_models.WareHouseModeEnum.UNRESTRICTED, + ) + + # 创建另一个产品和仓库用于测试过滤 + self.product2 = basic_models.Product.objects.create( + merchant=self.merchant, + category=self.category, + name='测试布料2', + human_id='FAB-002', + unit=basic_models.ProductUnitEnum.METER, + ) + self.warehouse2 = basic_models.WareHouse.objects.create( + merchant=self.merchant, + name='测试仓库2', + mode=basic_models.WareHouseModeEnum.UNRESTRICTED, + ) + + self.user = User.objects.create_user(username='detail_user', password='pass123') + self.employee = basic_models.Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='仓管员', + mobile='13800138001', + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + self.client.force_authenticate(user=self.user) + + # 创建多条库存变动记录和明细用于测试 + self.record1, self.details1, _ = stock_services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=self.user, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse.id, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=1, + products=[{'product': self.product.id, 'quantity': [Decimal('12.50'), Decimal('8.75')]}], + ) + + self.record2, self.details2, _ = stock_services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=self.user, + type=stock_models.StockChangeTypeEnum.REMOVE, + warehouse_id=self.warehouse.id, + source_type=stock_models.StockChangeSourceEnum.SALES, + source_id=2, + products=[{'product': self.product.id, 'quantity': [Decimal('5.00')]}], + ) + + # 在另一个仓库中创建记录,用于测试仓库过滤 + self.record3, self.details3, _ = stock_services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=self.user, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse2.id, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=3, + products=[{'product': self.product.id, 'quantity': [Decimal('10.00')]}], + ) + + # 在同一个仓库中为另一个产品创建记录,用于测试产品过滤 + self.record4, self.details4, _ = stock_services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=self.user, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse.id, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=4, + products=[{'product': self.product2.id, 'quantity': [Decimal('15.00')]}], + ) + + def test_list_stock_change_details_success(self): + """测试成功获取库存变动明细列表""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product.id}' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 应该返回仓库1中产品1的所有明细(record1和record2的明细) + self.assertEqual(response.data['count'], 3) # record1有2条明细,record2有1条明细 + self.assertEqual(len(response.data['results']), 3) + + # 检查返回数据结构 + result = response.data['results'][0] + self.assertIn('id', result) + self.assertIn('product', result) + self.assertIn('product_name', result) + self.assertIn('quantity', result) + self.assertIn('unit_display', result) + self.assertIn('stock_change_record', result) + self.assertIn('record_type', result) + self.assertIn('record_type_display', result) + self.assertIn('source_type', result) + self.assertIn('source_type_display', result) + self.assertIn('record_created_at', result) + self.assertIn('record_created_by', result) + + # 检查过滤条件 + self.assertEqual(response.data['filters']['warehouse_id'], self.warehouse.id) + self.assertEqual(response.data['filters']['product_id'], self.product.id) + + # 检查结果按创建时间降序排列 + dates = [result['record_created_at'] for result in response.data['results']] + self.assertEqual(dates, sorted(dates, reverse=True)) + + def test_list_stock_change_details_missing_warehouse_id(self): + """测试缺少warehouse_id参数""" + response = self.client.get( + f'/api/v1/stock-change-details/?product_id={self.product.id}' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺少必要参数', response.data['error']) + self.assertIn('warehouse_id', response.data['message']) + + def test_list_stock_change_details_missing_product_id(self): + """测试缺少product_id参数""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺少必要参数', response.data['error']) + self.assertIn('product_id', response.data['message']) + + def test_list_stock_change_details_invalid_warehouse_id(self): + """测试无效的warehouse_id参数""" + response = self.client.get( + '/api/v1/stock-change-details/?warehouse_id=invalid&product_id=1' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('参数格式错误', response.data['error']) + + def test_list_stock_change_details_invalid_product_id(self): + """测试无效的product_id参数""" + response = self.client.get( + '/api/v1/stock-change-details/?warehouse_id=1&product_id=invalid' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('参数格式错误', response.data['error']) + + def test_list_stock_change_details_different_warehouse(self): + """测试查询不同仓库的明细""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse2.id}&product_id={self.product.id}' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 应该只返回仓库2中产品1的明细(只有record3的明细) + self.assertEqual(response.data['count'], 1) + self.assertEqual(len(response.data['results']), 1) + + # 检查返回的明细属于正确的记录 + result = response.data['results'][0] + self.assertEqual(result['stock_change_record'], self.record3.id) + + def test_list_stock_change_details_different_product(self): + """测试查询不同产品的明细""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product2.id}' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 应该只返回仓库1中产品2的明细(只有record4的明细) + self.assertEqual(response.data['count'], 1) + self.assertEqual(len(response.data['results']), 1) + + # 检查返回的明细属于正确的记录 + result = response.data['results'][0] + self.assertEqual(result['stock_change_record'], self.record4.id) + + def test_list_stock_change_details_no_results(self): + """测试查询不存在的仓库或产品组合""" + # 使用不存在的仓库ID + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id=9999&product_id={self.product.id}' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('对当前用户不可见', response.data['error']) + + def test_list_stock_change_details_unauthenticated(self): + """测试未认证用户访问""" + self.client.force_authenticate(user=None) + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product.id}' + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_list_stock_change_details_no_employee(self): + """测试无员工信息的用户访问""" + # 创建一个没有关联员工的用户 + user_no_employee = User.objects.create_user(username='no_employee', password='pass123') + self.client.force_authenticate(user=user_no_employee) + + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product.id}' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('无权限访问', response.data['error']) + + def test_list_stock_change_details_with_direction_inbound(self): + """测试direction参数为inbound(入库)""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product.id}&direction=inbound' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 应该只返回入库记录的明细(record1的明细) + self.assertEqual(response.data['count'], 2) # record1有2条明细 + self.assertEqual(len(response.data['results']), 2) + + # 检查过滤条件 + self.assertEqual(response.data['filters']['direction'], 'inbound') + + # 检查所有记录都是入库记录 + for result in response.data['results']: + self.assertEqual(result['record_type'], stock_models.StockChangeTypeEnum.ADD) + self.assertEqual(result['record_type_display'], '入库') + + def test_list_stock_change_details_with_direction_outbound(self): + """测试direction参数为outbound(出库)""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product.id}&direction=outbound' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 应该只返回出库记录的明细(record2的明细) + self.assertEqual(response.data['count'], 1) # record2有1条明细 + self.assertEqual(len(response.data['results']), 1) + + # 检查过滤条件 + self.assertEqual(response.data['filters']['direction'], 'outbound') + + # 检查所有记录都是出库记录 + for result in response.data['results']: + self.assertEqual(result['record_type'], stock_models.StockChangeTypeEnum.REMOVE) + self.assertEqual(result['record_type_display'], '出库') + + def test_list_stock_change_details_with_invalid_direction(self): + """测试无效的direction参数""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product.id}&direction=invalid' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('参数格式错误', response.data['error']) + self.assertIn('direction 参数必须为 inbound 或 outbound', response.data['message']) + + def test_list_stock_change_details_default_direction(self): + """测试默认direction参数(应该是inbound)""" + response = self.client.get( + f'/api/v1/stock-change-details/?warehouse_id={self.warehouse.id}&product_id={self.product.id}' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 默认应该是inbound,所以应该只返回入库记录的明细(record1的明细) + self.assertEqual(response.data['count'], 2) # record1有2条明细 + self.assertEqual(len(response.data['results']), 2) + + # 检查过滤条件 + self.assertEqual(response.data['filters']['direction'], 'inbound') + + # 检查所有记录都是入库记录 + for result in response.data['results']: + self.assertEqual(result['record_type'], stock_models.StockChangeTypeEnum.ADD) + self.assertEqual(result['record_type_display'], '入库') +