1
0
forked from erp-dev/erp

feat: running on docker fully, and added rabbitmq/worker container

This commit is contained in:
2025-11-25 15:56:13 +08:00
parent cd6a2370fc
commit d90917e764
34 changed files with 2474 additions and 74 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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