forked from erp-dev/erp
feat: transfer api for stock module
This commit is contained in:
@@ -154,6 +154,86 @@ class SetMerchantAutoCompleteStockChangeSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class StockTransferProductSerializer(serializers.Serializer):
|
||||
product_id = serializers.IntegerField()
|
||||
quantities = serializers.ListField(
|
||||
child=serializers.DecimalField(max_digits=10, decimal_places=2),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
)
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||
num_of_rolls = serializers.IntegerField(required=False)
|
||||
unit_count = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||
consume_detail_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
has_quantities = bool(attrs.get('quantities'))
|
||||
has_value = attrs.get('value') is not None
|
||||
has_consumes = bool(attrs.get('consume_detail_ids'))
|
||||
if not (has_quantities or has_value or has_consumes):
|
||||
raise serializers.ValidationError('必须提供 quantities、value 或 consume_detail_ids 之一')
|
||||
return attrs
|
||||
|
||||
|
||||
class StockTransferSerializer(serializers.Serializer):
|
||||
from_warehouse = serializers.IntegerField()
|
||||
to_warehouse = serializers.IntegerField()
|
||||
transfer_date = serializers.DateField(required=False)
|
||||
remarks = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=500)
|
||||
request_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=64)
|
||||
products = StockTransferProductSerializer(many=True)
|
||||
|
||||
def validate_products(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('产品列表不能为空')
|
||||
return value
|
||||
|
||||
|
||||
class TransferOrderItemSerializer(serializers.ModelSerializer):
|
||||
product_name = serializers.CharField(source='product.name', read_only=True)
|
||||
unit_display = serializers.CharField(source='get_unit_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = stock_models.TransferOrderItem
|
||||
fields = ['id', 'product', 'product_name', 'total_quantity', 'unit', 'unit_display', 'num_of_rolls']
|
||||
|
||||
|
||||
class StockTransferResponseSerializer(serializers.ModelSerializer):
|
||||
items = TransferOrderItemSerializer(many=True, read_only=True)
|
||||
from_warehouse_name = serializers.CharField(source='from_warehouse.name', read_only=True)
|
||||
to_warehouse_name = serializers.CharField(source='to_warehouse.name', read_only=True)
|
||||
mode_display = serializers.CharField(source='get_mode_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
outgoing_record_id = serializers.IntegerField(source='outgoing_record.id', read_only=True)
|
||||
incoming_record_id = serializers.IntegerField(source='incoming_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = stock_models.TransferOrder
|
||||
fields = [
|
||||
'id',
|
||||
'merchant',
|
||||
'from_warehouse',
|
||||
'from_warehouse_name',
|
||||
'to_warehouse',
|
||||
'to_warehouse_name',
|
||||
'mode',
|
||||
'mode_display',
|
||||
'operator',
|
||||
'transfer_date',
|
||||
'status',
|
||||
'status_display',
|
||||
'remarks',
|
||||
'request_id',
|
||||
'outgoing_record_id',
|
||||
'incoming_record_id',
|
||||
'items',
|
||||
]
|
||||
|
||||
|
||||
# ==================== 通用文件上传序列化器 ====================
|
||||
|
||||
class UploadedFileSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -48,6 +48,7 @@ urlpatterns = [
|
||||
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/transfer/', stock_change_views.transfer_stock_change, name='stock_transfer'),
|
||||
path('stock-change/<int:record_id>/', stock_change_views.get_stock_change, name='get_stock_change'),
|
||||
path('stock-change/<int:record_id>/finish/', stock_change_views.finish_stock_change, name='finish_stock_change'),
|
||||
path('stock-change/<int:record_id>/offset/', stock_change_views.offset_stock_change, name='offset_stock_change'),
|
||||
|
||||
@@ -15,6 +15,7 @@ from .detail import GetStockChangeView
|
||||
from .finish import FinishStockChangeView
|
||||
from .settings import SetMerchantAutoCompleteView
|
||||
from .offset import OffsetStockChangeView
|
||||
from .transfer import StockTransferView
|
||||
|
||||
# 向后兼容:保持原有的函数式接口
|
||||
create_full_stock_change = CreateStockChangeView.as_view()
|
||||
@@ -26,6 +27,7 @@ get_stock_change = GetStockChangeView.as_view()
|
||||
finish_stock_change = FinishStockChangeView.as_view()
|
||||
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
|
||||
offset_stock_change = OffsetStockChangeView.as_view()
|
||||
transfer_stock_change = StockTransferView.as_view()
|
||||
|
||||
__all__ = [
|
||||
'StockChangeViewMixin',
|
||||
@@ -38,6 +40,7 @@ __all__ = [
|
||||
'FinishStockChangeView',
|
||||
'SetMerchantAutoCompleteView',
|
||||
'OffsetStockChangeView',
|
||||
'StockTransferView',
|
||||
'create_full_stock_change',
|
||||
'create_relaxed_stock_change',
|
||||
'create_restrict_stock_change',
|
||||
@@ -47,4 +50,5 @@ __all__ = [
|
||||
'finish_stock_change',
|
||||
'set_merchant_auto_complete_stock_change',
|
||||
'offset_stock_change',
|
||||
'transfer_stock_change',
|
||||
]
|
||||
|
||||
@@ -856,3 +856,89 @@ class OffsetStockChangeAPITestCase(TestCase):
|
||||
response = self.client.post(self.offset_url, {}, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('reason', response.data)
|
||||
|
||||
|
||||
class TransferStockChangeAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='调拨商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='调拨布料',
|
||||
product_prefix='TRN',
|
||||
)
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='调拨面料',
|
||||
human_id='TRN-001',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
self.from_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='调出仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
self.to_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='调入仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
type=basic_models.WarehouseTypeEnum.SCATTERED,
|
||||
)
|
||||
self.relaxed_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='宽松仓',
|
||||
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
|
||||
)
|
||||
self.user = User.objects.create_user(username='transfer_user', password='pass123')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='调拨员',
|
||||
mobile='13800138007',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_transfer_success(self):
|
||||
payload = {
|
||||
'from_warehouse': self.from_warehouse.id,
|
||||
'to_warehouse': self.to_warehouse.id,
|
||||
'products': [
|
||||
{
|
||||
'product_id': self.product.id,
|
||||
'quantities': ['10.50', '5.25'],
|
||||
}
|
||||
],
|
||||
'remarks': '整仓到散仓',
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/transfer/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['status'], stock_models.TransferOrderStatusEnum.COMPLETED)
|
||||
self.assertEqual(response.data['from_warehouse'], self.from_warehouse.id)
|
||||
self.assertEqual(response.data['to_warehouse'], self.to_warehouse.id)
|
||||
self.assertEqual(len(response.data['items']), 1)
|
||||
self.assertEqual(
|
||||
Decimal(response.data['items'][0]['total_quantity']),
|
||||
Decimal('15.75'),
|
||||
)
|
||||
|
||||
def test_transfer_requires_same_mode(self):
|
||||
payload = {
|
||||
'from_warehouse': self.from_warehouse.id,
|
||||
'to_warehouse': self.relaxed_warehouse.id,
|
||||
'products': [
|
||||
{
|
||||
'product_id': self.product.id,
|
||||
'quantities': ['8.00'],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/transfer/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('模式一致', response.data['error'])
|
||||
|
||||
48
api_v1/views/stock_change_views/transfer.py
Normal file
48
api_v1/views/stock_change_views/transfer.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
|
||||
from rest_framework import status, views
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api_v1 import serializers
|
||||
from stock import services as stock_services
|
||||
|
||||
from .mixins import StockChangeViewMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockTransferView(StockChangeViewMixin, views.APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
if not self.check_employee_permission(request):
|
||||
return self.permission_error_response('无权限访问')
|
||||
|
||||
serializer = serializers.StockTransferSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
merchant = request.user.employee.merchant
|
||||
operator = request.user.employee
|
||||
|
||||
try:
|
||||
transfer_order = stock_services.create_transfer_order(
|
||||
merchant=merchant,
|
||||
created_by=request.user,
|
||||
operator=operator,
|
||||
from_warehouse_id=data['from_warehouse'],
|
||||
to_warehouse_id=data['to_warehouse'],
|
||||
items=data['products'],
|
||||
transfer_date=data.get('transfer_date'),
|
||||
remarks=data.get('remarks'),
|
||||
request_id=data.get('request_id') or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
logger.error('创建调拨单失败: %s', exc, exc_info=True)
|
||||
return Response({'error': '创建调拨单失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
response_serializer = serializers.StockTransferResponseSerializer(transfer_order)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
Reference in New Issue
Block a user