forked from erp-dev/erp
feat: running on docker fully, and added rabbitmq/worker container
This commit is contained in:
@@ -12,8 +12,15 @@ class ProductStockChangeSerializer(serializers.Serializer):
|
||||
quantity = serializers.ListField(
|
||||
child=serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.01')),
|
||||
min_length=1,
|
||||
required=False,
|
||||
help_text="数量列表,每个数量对应一条明细记录"
|
||||
)
|
||||
consume_with = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
help_text="严进严出模式(出库)指定的入库明细ID列表"
|
||||
)
|
||||
|
||||
def validate_product(self, value: int) -> int:
|
||||
"""验证产品是否存在"""
|
||||
@@ -23,6 +30,11 @@ class ProductStockChangeSerializer(serializers.Serializer):
|
||||
raise serializers.ValidationError(f"产品ID {value} 不存在")
|
||||
return value
|
||||
|
||||
def validate_consume_with(self, value: List[int]) -> List[int]:
|
||||
if len(value) != len(set(value)):
|
||||
raise serializers.ValidationError("consume_with 中存在重复的入库明细ID")
|
||||
return value
|
||||
|
||||
|
||||
class CreateStockChangeSerializer(serializers.Serializer):
|
||||
"""创建库存变动记录序列化器"""
|
||||
@@ -115,12 +127,14 @@ class StockChangeDetailResponseSerializer(serializers.ModelSerializer):
|
||||
"""库存变动明细响应序列化器"""
|
||||
product_name = serializers.CharField(source='product.name', read_only=True)
|
||||
unit_display = serializers.CharField(source='get_unit_display', read_only=True)
|
||||
consume_with_id = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = stock_models.StockChangeDetail
|
||||
fields = [
|
||||
'id', 'product', 'product_name', 'quantity',
|
||||
'unit', 'unit_display', 'stock_change_record'
|
||||
'unit', 'unit_display', 'stock_change_record',
|
||||
'is_consumed', 'consume_with_id',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ urlpatterns = [
|
||||
path('stock-changes/', stock_change_views.list_stock_changes, name='list_stock_changes'),
|
||||
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'),
|
||||
path('stock-change/<int:record_id>/', stock_change_views.get_stock_change, name='get_stock_change'),
|
||||
path(
|
||||
'set-merchant-auto-complete-stock-change/',
|
||||
|
||||
@@ -89,6 +89,7 @@ from api_v1.views import stock_change_views
|
||||
urlpatterns = [
|
||||
path('stock-change/', stock_change_views.create_full_stock_change),
|
||||
path('stock-change/relaxed/', stock_change_views.create_relaxed_stock_change),
|
||||
path('stock-change/restrict/', stock_change_views.create_restrict_stock_change),
|
||||
path('stock-changes/', stock_change_views.list_stock_changes),
|
||||
path('stock-change/<int:record_id>/', stock_change_views.get_stock_change),
|
||||
path('set-merchant-auto-complete-stock-change/', stock_change_views.set_merchant_auto_complete_stock_change),
|
||||
@@ -101,6 +102,7 @@ urlpatterns = [
|
||||
from api_v1.views.stock_change_views import (
|
||||
CreateStockChangeView,
|
||||
CreateStockChangeRelaxedView,
|
||||
CreateStockChangeRestrictView,
|
||||
ListStockChangesView,
|
||||
GetStockChangeView,
|
||||
SetMerchantAutoCompleteView,
|
||||
@@ -109,6 +111,7 @@ from api_v1.views.stock_change_views import (
|
||||
urlpatterns = [
|
||||
path('stock-change/', CreateStockChangeView.as_view()),
|
||||
path('stock-change/relaxed/', CreateStockChangeRelaxedView.as_view()),
|
||||
path('stock-change/restrict/', CreateStockChangeRestrictView.as_view()),
|
||||
path('stock-changes/', ListStockChangesView.as_view()),
|
||||
path('stock-change/<int:record_id>/', GetStockChangeView.as_view()),
|
||||
path('set-merchant-auto-complete-stock-change/', SetMerchantAutoCompleteView.as_view()),
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
"""
|
||||
|
||||
from .mixins import StockChangeViewMixin
|
||||
from .create import CreateStockChangeView, CreateStockChangeRelaxedView
|
||||
from .create import (
|
||||
CreateStockChangeView,
|
||||
CreateStockChangeRelaxedView,
|
||||
CreateStockChangeRestrictView,
|
||||
)
|
||||
from .list import ListStockChangesView
|
||||
from .detail import GetStockChangeView
|
||||
from .settings import SetMerchantAutoCompleteView
|
||||
@@ -13,6 +17,7 @@ from .settings import SetMerchantAutoCompleteView
|
||||
# 向后兼容:保持原有的函数式接口
|
||||
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()
|
||||
get_stock_change = GetStockChangeView.as_view()
|
||||
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
|
||||
@@ -21,11 +26,13 @@ __all__ = [
|
||||
'StockChangeViewMixin',
|
||||
'CreateStockChangeView',
|
||||
'CreateStockChangeRelaxedView',
|
||||
'CreateStockChangeRestrictView',
|
||||
'ListStockChangesView',
|
||||
'GetStockChangeView',
|
||||
'SetMerchantAutoCompleteView',
|
||||
'create_full_stock_change',
|
||||
'create_relaxed_stock_change',
|
||||
'create_restrict_stock_change',
|
||||
'list_stock_changes',
|
||||
'get_stock_change',
|
||||
'set_merchant_auto_complete_stock_change',
|
||||
|
||||
@@ -212,3 +212,88 @@ class CreateStockChangeRelaxedView(StockChangeViewMixin, views.APIView):
|
||||
'error': '创建库存变动记录失败',
|
||||
'message': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
class CreateStockChangeRestrictView(StockChangeViewMixin, views.APIView):
|
||||
"""严进严出模式接口"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=['创建出入库'],
|
||||
request=serializers.CreateStockChangeSerializer,
|
||||
responses={201: serializers.CreateStockChangeResponseSerializer},
|
||||
summary="创建库存变动记录(严进严出模式)",
|
||||
description="严进严出模式会在出库时按 consume_with 消耗入库明细"
|
||||
)
|
||||
def post(self, request):
|
||||
if not self.check_employee_permission(request):
|
||||
return self.permission_error_response('无权限访问')
|
||||
|
||||
record_data = {
|
||||
'type': request.data.get('type'),
|
||||
'warehouse': request.data.get('warehouse'),
|
||||
'source_type': request.data.get('source_type'),
|
||||
'source_id': request.data.get('source_id'),
|
||||
}
|
||||
|
||||
if not all([record_data['type'], record_data['warehouse'], record_data['source_type']]):
|
||||
return Response({
|
||||
'error': '缺少必要参数',
|
||||
'message': '请提供 type, warehouse, source_type'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
products_data = request.data.get('products', [])
|
||||
if not products_data:
|
||||
return Response({
|
||||
'error': '产品列表不能为空'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not self.validate_warehouse_visibility(record_data['warehouse'], request):
|
||||
return Response({
|
||||
'error': f'仓库ID {record_data["warehouse"]} 对当前用户不可见'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
for p in products_data:
|
||||
product_id = p.get('product')
|
||||
if not self.validate_product_visibility(product_id, request):
|
||||
return Response({
|
||||
'error': f'产品ID {product_id} 对当前用户不可见'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
serializer = serializers.CreateStockChangeSerializer(data={'products': products_data})
|
||||
if not serializer.is_valid():
|
||||
return Response({
|
||||
'error': '产品数据验证失败',
|
||||
'details': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
stock_change_record, created_details, created_count = stock_services.create_stock_change_record_with_details(
|
||||
merchant=request.user.employee.merchant,
|
||||
created_by=request.user,
|
||||
type=record_data['type'],
|
||||
warehouse_id=record_data['warehouse'],
|
||||
source_type=record_data['source_type'],
|
||||
source_id=record_data['source_id'],
|
||||
products=products_data,
|
||||
)
|
||||
response_serializer = serializers.CreateStockChangeResponseSerializer({
|
||||
'stock_change_record': stock_change_record,
|
||||
'details': created_details,
|
||||
'message': f'成功创建库存变动记录及 {created_count} 条明细',
|
||||
'created_details_count': created_count
|
||||
})
|
||||
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except ValueError as e:
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
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)
|
||||
|
||||
108
api_v1/views/stock_change_views/test_stock_change_api.py
Normal file
108
api_v1/views/stock_change_views/test_stock_change_api.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from stock import models as stock_models, services as stock_services
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class StockChangeRestrictAPITestCase(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.RESTRICT_IN_OUT,
|
||||
)
|
||||
|
||||
self.user = User.objects.create_user(username='restrict_user', password='pass123')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='仓管员',
|
||||
mobile='13800138000',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# 创建一条入库明细供消耗
|
||||
_, details, _ = 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')]}],
|
||||
)
|
||||
self.inbound_detail = details[0]
|
||||
|
||||
def test_restrict_outgoing_consumes_inbound_detail(self):
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.REMOVE,
|
||||
'warehouse': self.warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.SALES,
|
||||
'source_id': 2,
|
||||
'products': [
|
||||
{
|
||||
'product': self.product.id,
|
||||
'consume_with': [self.inbound_detail.id],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/restrict/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.inbound_detail.refresh_from_db()
|
||||
self.assertTrue(self.inbound_detail.is_consumed)
|
||||
|
||||
detail = response.data['details'][0]
|
||||
self.assertEqual(detail['consume_with_id'], self.inbound_detail.id)
|
||||
self.assertEqual(
|
||||
Decimal(str(detail['quantity'])),
|
||||
self.inbound_detail.quantity,
|
||||
)
|
||||
|
||||
def test_restrict_outgoing_requires_consume_with(self):
|
||||
payload = {
|
||||
'type': stock_models.StockChangeTypeEnum.REMOVE,
|
||||
'warehouse': self.warehouse.id,
|
||||
'source_type': stock_models.StockChangeSourceEnum.SALES,
|
||||
'source_id': 3,
|
||||
'products': [
|
||||
{
|
||||
'product': self.product.id,
|
||||
'quantity': ['10.00'],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/restrict/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('consume_with', str(response.data))
|
||||
|
||||
Reference in New Issue
Block a user