1
0
forked from erp-dev/erp

feat: query api for stock_change_detail

This commit is contained in:
2025-11-25 18:08:42 +08:00
parent 9b8f6dd57c
commit 6bf0465d05
5 changed files with 434 additions and 2 deletions

View File

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

View File

@@ -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',
]

View File

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

View File

@@ -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'], '入库')