forked from erp-dev/erp
feat: query api for stock_change_detail
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'], '入库')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user