1
0
forked from erp-dev/erp

feat: relaxed in restrict out

This commit is contained in:
2025-11-24 15:57:19 +08:00
parent 113818f73d
commit cd6a2370fc
11 changed files with 840 additions and 5 deletions

View File

@@ -68,6 +68,13 @@ Base URL: `/api_man/user-profiles/`
| PATCH | `/api_man/user-profiles/{id}/` | Partially update an existing user profile | | PATCH | `/api_man/user-profiles/{id}/` | Partially update an existing user profile |
| DELETE | `/api_man/user-profiles/{id}/` | Delete a user profile | | DELETE | `/api_man/user-profiles/{id}/` | Delete a user profile |
### 2.3 User Creation API Endpoint
Base URL: `/api_v1/users/`
| Method | URL Pattern | Action |
|--------|-------------|--------|
| POST | `/api_v1/users/create/` | Create a new User and associated UserProfile |
## 3. Binding User to Employee ## 3. Binding User to Employee
### 3.1 During Employee Creation ### 3.1 During Employee Creation
@@ -216,7 +223,70 @@ To update an existing UserProfile, use the PUT or PATCH method.
} }
``` ```
## 5. Validation Rules ## 5. Creating User with Profile
### 5.1 User and Profile Creation
This API creates a new Django User and associated UserProfile in a single request. The UserProfile links the User to a specific Merchant.
#### Request Body Example:
```json
{
"username": "newuser",
"email": "newuser@example.com",
"password": "securepass123",
"is_staff": false,
"description": "New user account",
"merchant_id": 1
}
```
#### Request Parameters:
- `username` (required): The username for the new user account
- `email` (optional): The email address for the user
- `password` (required): The password for the user account (minimum 6 characters)
- `is_staff` (optional, default: false): Whether the user should have staff privileges
- `description` (optional): Description for the UserProfile
- `merchant_id` (required): ID of the merchant to associate with the user
#### Response Example:
```json
{
"user": {
"id": 124,
"username": "newuser",
"email": "newuser@example.com",
"is_staff": false,
"is_active": true,
"date_joined": "2025-11-24T14:00:00Z"
},
"profile": {
"id": 790,
"user": {
"id": 124,
"username": "newuser",
"email": "newuser@example.com",
"is_staff": false,
"is_active": true,
"date_joined": "2025-11-24T14:00:00Z"
},
"merchant": 1,
"description": "New user account",
"created_at": "2025-11-24T14:00:00Z",
"updated_at": "2025-11-24T14:00:00Z"
}
}
```
### 5.2 Authentication Requirements
- The request must be authenticated with a valid user session
- The authenticated user must have appropriate permissions to create new users
### 5.3 Validation Rules
- Username must be unique across the system
- Password must be at least 6 characters long
- merchant_id must correspond to an existing merchant in the system
## 6. Validation Rules
### 5.1 Employee-User Binding Rules ### 5.1 Employee-User Binding Rules
1. **One-to-one constraint**: A User can only be bound to one Employee at a time. 1. **One-to-one constraint**: A User can only be bound to one Employee at a time.
@@ -250,6 +320,78 @@ To update an existing UserProfile, use the PUT or PATCH method.
} }
``` ```
### 6.1 User Creation API Errors
#### Username already exists
```json
{
"username": [
"用户名已存在"
]
}
```
#### Merchant does not exist
```json
{
"merchant_id": [
"商户不存在"
]
}
```
#### Password too short
```json
{
"password": [
"Ensure this field has at least 6 characters."
]
}
```
#### Unauthenticated request
```json
{
"error": "未授权"
}
```
#### Server error during creation
```json
{
"error": "创建用户失败: [detailed error message]"
}
```
## 7. Usage Examples
### 7.1 Creating a Complete User Flow
```bash
# 1. Create a new user with profile
curl -X POST http://localhost/api/v1/users/create/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer [token]" \
-d '{
"username": "john_doe",
"email": "john@example.com",
"password": "securepass123",
"description": "Store employee",
"merchant_id": 1
}'
# Response will contain both user and profile IDs
# Use the user ID to create an Employee record and bind the user
curl -X POST http://localhost/api_man/employees/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer [token]" \
-d '{
"name": "John Doe",
"position": 1,
"mobile": "1234567890",
"sys_user": 124 # User ID from the previous response
}'
```
### Invalid User ID ### Invalid User ID
```json ```json
{ {

View File

@@ -55,6 +55,51 @@ class CreateStockChangeSerializer(serializers.Serializer):
return value return value
class RelaxedQuantitySerializer(serializers.Serializer):
"""宽松模式数量结构"""
value = serializers.DecimalField(
max_digits=10,
decimal_places=2,
min_value=Decimal('0.01'),
help_text="产品总数量"
)
unit_count = serializers.DecimalField(
max_digits=10,
decimal_places=2,
min_value=Decimal('0.01'),
default=Decimal('1.00'),
help_text="单条(匹)数量,默认 1"
)
class RelaxedProductStockChangeSerializer(serializers.Serializer):
"""宽松模式产品序列化器"""
product = serializers.IntegerField(help_text="产品ID")
quantity = RelaxedQuantitySerializer(help_text="数量定义(总数与单条数)")
def validate_product(self, value: int) -> int:
try:
basic_info_models.Product.objects.get(id=value)
except basic_info_models.Product.DoesNotExist:
raise serializers.ValidationError(f"产品ID {value} 不存在")
return value
class CreateStockChangeRelaxedSerializer(serializers.Serializer):
"""宽松模式创建库存变动记录"""
products = RelaxedProductStockChangeSerializer(many=True, help_text="产品列表")
def validate_products(self, value: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if not value:
raise serializers.ValidationError("产品列表不能为空")
product_ids = [item['product'] for item in value]
if len(product_ids) != len(set(product_ids)):
raise serializers.ValidationError("产品列表中存在重复的产品ID")
return value
class StockChangeRecordResponseSerializer(serializers.ModelSerializer): class StockChangeRecordResponseSerializer(serializers.ModelSerializer):
"""库存变动记录响应序列化器""" """库存变动记录响应序列化器"""

View File

@@ -1,3 +1,112 @@
from django.test import TestCase from django.test import TestCase
from django.contrib.auth.models import User, Permission
from rest_framework.test import APIClient
from rest_framework import status
from basic_info.models import Merchant, UserProfile
# Create your tests here.
class UserCreationAPITestCase(TestCase):
"""测试用户创建 API"""
def setUp(self):
"""设置测试数据"""
# 创建商户
self.merchant = Merchant.objects.create(
name='测试商户',
type=1 # 假设1是有效的MerchantType
)
# 创建管理员用户
self.admin_user = User.objects.create_user(
username='admin',
password='adminpass123',
is_staff=True
)
# 授予创建用户所需的权限
add_user_perm = Permission.objects.get(codename='add_user')
self.admin_user.user_permissions.add(add_user_perm)
self.admin_user.save()
# 设置 API 客户端
self.client = APIClient()
self.client.force_authenticate(user=self.admin_user)
def test_create_user_with_profile(self):
"""测试创建用户和用户资料"""
data = {
'username': 'testuser',
'email': 'test@example.com',
'password': 'testpass123',
'is_staff': False,
'description': '测试用户资料',
'merchant_id': self.merchant.id
}
response = self.client.post('/api/v1/users/create/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# 验证返回的数据
self.assertIn('user', response.data)
self.assertIn('profile', response.data)
self.assertEqual(response.data['user']['username'], 'testuser')
self.assertEqual(response.data['user']['email'], 'test@example.com')
self.assertEqual(response.data['user']['is_staff'], False)
self.assertEqual(response.data['profile']['description'], '测试用户资料')
self.assertEqual(response.data['profile']['merchant'], self.merchant.id)
# 验证数据库中的用户
user = User.objects.get(username='testuser')
self.assertEqual(user.email, 'test@example.com')
self.assertFalse(user.is_staff)
# 验证数据库中的用户资料
profile = UserProfile.objects.get(user=user)
self.assertEqual(profile.merchant, self.merchant)
self.assertEqual(profile.description, '测试用户资料')
def test_create_user_with_duplicate_username(self):
"""测试创建用户时使用重复的用户名"""
# 先创建一个用户
User.objects.create_user(username='existinguser', password='pass123')
data = {
'username': 'existinguser', # 重复的用户名
'password': 'testpass123',
'merchant_id': self.merchant.id
}
response = self.client.post('/api/v1/users/create/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('username', response.data)
def test_create_user_with_invalid_merchant(self):
"""测试创建用户时使用无效的merchant_id"""
data = {
'username': 'testuser',
'password': 'testpass123',
'merchant_id': 999 # 不存在的merchant_id
}
response = self.client.post('/api/v1/users/create/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('merchant_id', response.data)
def test_create_user_unauthenticated(self):
"""测试未认证用户创建用户"""
self.client.force_authenticate(user=None)
data = {
'username': 'testuser',
'password': 'testpass123',
'merchant_id': self.merchant.id
}
response = self.client.post('/api/v1/users/create/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_create_user_with_short_password(self):
"""测试创建用户时密码过短"""
data = {
'username': 'testuser',
'password': '123', # 密码过短
'merchant_id': self.merchant.id
}
response = self.client.post('/api/v1/users/create/', data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('password', response.data)

View File

@@ -1,11 +1,12 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import stock_change_views, user_info, inventory, product_image, stateflow from .views import stock_change_views, user_info, inventory, product_image, stateflow, users
from .views.stock_change_views.snapshot import StockSnapshotListView from .views.stock_change_views.snapshot import StockSnapshotListView
from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet from .views.printing.views import PrintingOrderViewSet, PrintingJobViewSet, PlateOrderViewSet
from .views.upload import UploadFileViewSet from .views.upload import UploadFileViewSet
from .views.products import ProductQuickViewSet from .views.products import ProductQuickViewSet
from .views.parameters import StateParameterViewSet from .views.parameters import StateParameterViewSet
from .views.users import CreateUserWithProfileView
# 创建 DRF Router for Stateflow # 创建 DRF Router for Stateflow
stateflow_router = DefaultRouter() stateflow_router = DefaultRouter()
@@ -27,6 +28,7 @@ urlpatterns = [
path('stock-snapshots/', StockSnapshotListView.as_view(), name='list_stock_snapshots'), 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-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/', 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/<int:record_id>/', stock_change_views.get_stock_change, name='get_stock_change'), path('stock-change/<int:record_id>/', stock_change_views.get_stock_change, name='get_stock_change'),
path( path(
'set-merchant-auto-complete-stock-change/', 'set-merchant-auto-complete-stock-change/',
@@ -37,6 +39,9 @@ urlpatterns = [
# 用户信息 API # 用户信息 API
path('user-info/', user_info.user_info, name='user_info'), path('user-info/', user_info.user_info, name='user_info'),
# 用户创建 API
path('users/create/', CreateUserWithProfileView.as_view(), name='create_user_with_profile'),
# 库存查询 API # 库存查询 API
path('inventory/', inventory.InventoryAPIView.as_view(), name='inventory'), path('inventory/', inventory.InventoryAPIView.as_view(), name='inventory'),

View File

@@ -20,4 +20,5 @@ __all__ = [
'get_stock_change', 'get_stock_change',
'set_merchant_auto_complete_stock_change', 'set_merchant_auto_complete_stock_change',
'stateflow', 'stateflow',
'create_user_with_profile',
] ]

View File

@@ -63,6 +63,7 @@ stock_change_views/
```python ```python
# 这些接口保持不变URL 配置无需修改 # 这些接口保持不变URL 配置无需修改
create_full_stock_change = CreateStockChangeView.as_view() create_full_stock_change = CreateStockChangeView.as_view()
create_relaxed_stock_change = CreateStockChangeRelaxedView.as_view()
list_stock_changes = ListStockChangesView.as_view() list_stock_changes = ListStockChangesView.as_view()
get_stock_change = GetStockChangeView.as_view() get_stock_change = GetStockChangeView.as_view()
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view() set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
@@ -87,6 +88,7 @@ from api_v1.views import stock_change_views
urlpatterns = [ urlpatterns = [
path('stock-change/', stock_change_views.create_full_stock_change), path('stock-change/', stock_change_views.create_full_stock_change),
path('stock-change/relaxed/', stock_change_views.create_relaxed_stock_change),
path('stock-changes/', stock_change_views.list_stock_changes), path('stock-changes/', stock_change_views.list_stock_changes),
path('stock-change/<int:record_id>/', stock_change_views.get_stock_change), 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), path('set-merchant-auto-complete-stock-change/', stock_change_views.set_merchant_auto_complete_stock_change),
@@ -98,6 +100,7 @@ urlpatterns = [
```python ```python
from api_v1.views.stock_change_views import ( from api_v1.views.stock_change_views import (
CreateStockChangeView, CreateStockChangeView,
CreateStockChangeRelaxedView,
ListStockChangesView, ListStockChangesView,
GetStockChangeView, GetStockChangeView,
SetMerchantAutoCompleteView, SetMerchantAutoCompleteView,
@@ -105,6 +108,7 @@ from api_v1.views.stock_change_views import (
urlpatterns = [ urlpatterns = [
path('stock-change/', CreateStockChangeView.as_view()), path('stock-change/', CreateStockChangeView.as_view()),
path('stock-change/relaxed/', CreateStockChangeRelaxedView.as_view()),
path('stock-changes/', ListStockChangesView.as_view()), path('stock-changes/', ListStockChangesView.as_view()),
path('stock-change/<int:record_id>/', GetStockChangeView.as_view()), path('stock-change/<int:record_id>/', GetStockChangeView.as_view()),
path('set-merchant-auto-complete-stock-change/', SetMerchantAutoCompleteView.as_view()), path('set-merchant-auto-complete-stock-change/', SetMerchantAutoCompleteView.as_view()),

View File

@@ -5,13 +5,14 @@
""" """
from .mixins import StockChangeViewMixin from .mixins import StockChangeViewMixin
from .create import CreateStockChangeView from .create import CreateStockChangeView, CreateStockChangeRelaxedView
from .list import ListStockChangesView from .list import ListStockChangesView
from .detail import GetStockChangeView from .detail import GetStockChangeView
from .settings import SetMerchantAutoCompleteView from .settings import SetMerchantAutoCompleteView
# 向后兼容:保持原有的函数式接口 # 向后兼容:保持原有的函数式接口
create_full_stock_change = CreateStockChangeView.as_view() create_full_stock_change = CreateStockChangeView.as_view()
create_relaxed_stock_change = CreateStockChangeRelaxedView.as_view()
list_stock_changes = ListStockChangesView.as_view() list_stock_changes = ListStockChangesView.as_view()
get_stock_change = GetStockChangeView.as_view() get_stock_change = GetStockChangeView.as_view()
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view() set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
@@ -19,10 +20,12 @@ set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
__all__ = [ __all__ = [
'StockChangeViewMixin', 'StockChangeViewMixin',
'CreateStockChangeView', 'CreateStockChangeView',
'CreateStockChangeRelaxedView',
'ListStockChangesView', 'ListStockChangesView',
'GetStockChangeView', 'GetStockChangeView',
'SetMerchantAutoCompleteView', 'SetMerchantAutoCompleteView',
'create_full_stock_change', 'create_full_stock_change',
'create_relaxed_stock_change',
'list_stock_changes', 'list_stock_changes',
'get_stock_change', 'get_stock_change',
'set_merchant_auto_complete_stock_change', 'set_merchant_auto_complete_stock_change',

View File

@@ -126,3 +126,89 @@ class CreateStockChangeView(StockChangeViewMixin, views.APIView):
'error': '创建库存变动记录失败', 'error': '创建库存变动记录失败',
'message': str(e) 'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class CreateStockChangeRelaxedView(StockChangeViewMixin, views.APIView):
"""宽松模式:根据总量与单条数量拆分的出入库记录"""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=['创建出入库'],
request=serializers.CreateStockChangeRelaxedSerializer,
responses={201: serializers.CreateStockChangeResponseSerializer},
summary="创建库存变动记录(宽松模式)",
description="根据总数量与单条数量自动拆分明细"
)
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.CreateStockChangeRelaxedSerializer(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_relaxed(
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)

123
api_v1/views/users.py Normal file
View File

@@ -0,0 +1,123 @@
from django.contrib.auth.models import User
from rest_framework.decorators import permission_classes
from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions
from rest_framework.response import Response
from rest_framework.pagination import LimitOffsetPagination
from rest_framework import status, serializers
from rest_framework.generics import GenericAPIView
from basic_info.models import UserProfile
class UserCreationSerializer(serializers.Serializer):
"""用户创建序列化器"""
username = serializers.CharField(max_length=150)
email = serializers.EmailField(required=False)
password = serializers.CharField(min_length=6, write_only=True)
is_staff = serializers.BooleanField(default=False)
description = serializers.CharField(required=False, allow_blank=True)
merchant_id = serializers.IntegerField()
def validate_username(self, value):
"""验证用户名是否已存在"""
if User.objects.filter(username=value).exists():
raise serializers.ValidationError("用户名已存在")
return value
def validate_merchant_id(self, value):
"""验证merchant_id是否存在"""
from basic_info.models import Merchant
if not Merchant.objects.filter(id=value).exists():
raise serializers.ValidationError("商户不存在")
return value
class UserSerializer(serializers.ModelSerializer):
"""用户信息序列化器"""
class Meta:
model = User
fields = ['id', 'username', 'email', 'is_staff', 'is_active', 'date_joined']
class UserProfileDetailSerializer(serializers.ModelSerializer):
"""用户资料详细信息序列化器"""
user = UserSerializer(read_only=True)
class Meta:
model = UserProfile
fields = ['id', 'user', 'merchant', 'description', 'created_at', 'updated_at']
class CreateUserWithProfileView(GenericAPIView):
"""创建用户和关联的用户资料信息视图"""
queryset = User.objects.all()
permission_classes = [IsAuthenticated, DjangoModelPermissions]
pagination_class = LimitOffsetPagination
def post(self, request):
"""
创建用户和用户资料
POST /api/v1/users/create/
请求参数:
- username: 用户名 (必需)
- email: 邮箱 (可选)
- password: 密码 (必需, 最少6位)
- is_staff: 是否为员工 (可选, 默认为False)
- description: 用户资料描述 (可选)
- merchant_id: 商户ID (必需)
返回:
- user: 创建的用户信息
- profile: 创建的用户资料信息
"""
# 检查当前用户是否有权限(超级用户或有员工身份)
if not request.user.is_authenticated:
return Response({'error': '未授权'}, status=status.HTTP_401_UNAUTHORIZED)
# 序列化和验证请求数据
serializer = UserCreationSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
username = serializer.validated_data['username']
email = serializer.validated_data.get('email', '')
password = serializer.validated_data['password']
is_staff = serializer.validated_data.get('is_staff', False)
description = serializer.validated_data.get('description', '')
merchant_id = serializer.validated_data['merchant_id']
try:
# 创建用户
user = User.objects.create_user(
username=username,
email=email,
password=password,
is_staff=is_staff
)
# 创建用户资料
from basic_info.models import Merchant
merchant = Merchant.objects.get(id=merchant_id)
profile = UserProfile.objects.create(
user=user,
merchant=merchant,
description=description
)
# 返回创建的用户和用户资料信息
user_data = UserSerializer(user).data
profile_data = UserProfileDetailSerializer(profile).data
return Response({
'user': user_data,
'profile': profile_data
}, status=status.HTTP_201_CREATED)
except Exception as e:
# 如果创建过程中出现错误,删除已创建的用户
if 'user' in locals():
user.delete()
return Response({
'error': f'创建用户失败: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -4,6 +4,7 @@ from django.db.models import Sum
from django.utils import timezone from django.utils import timezone
from sse.services import push_simple_message_with_object_id from sse.services import push_simple_message_with_object_id
from basic_info import models as basic_models from basic_info import models as basic_models
from decimal import Decimal, InvalidOperation
import logging import logging
from typing import List, Dict, Any, Tuple from typing import List, Dict, Any, Tuple
@@ -11,6 +12,26 @@ from typing import List, Dict, Any, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _raise_unimplemented_mode():
raise ValueError('仓库出入库模式为【严进严出】,该模式暂未支持创建出入库记录')
def _ensure_strict_mode(warehouse: basic_models.WareHouse):
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN:
return
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
_raise_unimplemented_mode()
raise ValueError('仓库出入库模式为【宽进宽出】,请使用宽松模式接口创建出入库记录')
def _ensure_relaxed_mode(warehouse: basic_models.WareHouse):
if warehouse.mode == basic_models.WareHouseModeEnum.UNRESTRICTED:
return
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
_raise_unimplemented_mode()
raise ValueError('仓库出入库模式为【严进宽出】,请使用严谨模式接口创建出入库记录')
def create_stock_change_record_with_details( def create_stock_change_record_with_details(
*, *,
merchant: basic_models.Merchant, merchant: basic_models.Merchant,
@@ -48,6 +69,7 @@ def create_stock_change_record_with_details(
if warehouse.merchant_id != merchant.id: if warehouse.merchant_id != merchant.id:
raise ValueError('仓库不属于当前商户') raise ValueError('仓库不属于当前商户')
_ensure_strict_mode(warehouse)
created_details: List[models.StockChangeDetail] = [] created_details: List[models.StockChangeDetail] = []
created_count = 0 created_count = 0
@@ -96,6 +118,116 @@ def create_stock_change_record_with_details(
return stock_change_record, created_details, created_count return stock_change_record, created_details, created_count
def _to_decimal(value, field_name: str) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError):
raise ValueError(f'{field_name} 必须是合法的数值')
def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]:
if total <= 0:
raise ValueError('quantity.value 必须大于 0')
if unit_size <= 0:
raise ValueError('quantity.unit_count 必须大于 0')
quantities: List[Decimal] = []
num_full = int(total // unit_size)
remainder = total % unit_size
if num_full == 0:
quantities.append(total)
else:
quantities.extend([unit_size] * num_full)
if remainder > 0:
quantities.append(remainder)
return quantities
def create_stock_change_record_relaxed(
*,
merchant: basic_models.Merchant,
created_by,
type: int,
warehouse_id: int,
source_type: int,
source_id: int | None = None,
products: List[Dict[str, Any]] | None = None,
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]:
"""
宽松模式:根据总数量和单条数量自动拆分明细
"""
if not merchant:
raise ValueError('必须提供商户信息')
if not warehouse_id:
raise ValueError('必须提供仓库ID')
if not products:
raise ValueError('产品列表不能为空')
try:
warehouse = basic_models.WareHouse.objects.get(id=warehouse_id)
except basic_models.WareHouse.DoesNotExist:
raise ValueError(f'仓库ID {warehouse_id} 不存在')
if warehouse.merchant_id != merchant.id:
raise ValueError('仓库不属于当前商户')
_ensure_relaxed_mode(warehouse)
created_details: List[models.StockChangeDetail] = []
with transaction.atomic():
stock_change_record = models.StockChangeRecord.objects.create(
type=type,
warehouse=warehouse,
source_type=source_type,
source_id=source_id,
merchant=merchant,
created_by=created_by,
)
logger.info('创建宽松模式库存变动记录 ID: %s', stock_change_record.id)
for product_data in products:
product_id = product_data.get('product')
quantity_data = product_data.get('quantity') or {}
if not product_id:
raise ValueError('产品ID不能为空')
total_value = quantity_data.get('value')
unit_size = quantity_data.get('unit_count', 1)
total_value = _to_decimal(total_value, 'quantity.value')
unit_size = _to_decimal(unit_size, 'quantity.unit_count')
try:
product = basic_models.Product.objects.get(id=product_id)
except basic_models.Product.DoesNotExist:
raise ValueError(f'产品ID {product_id} 不存在')
if product.merchant_id != merchant.id:
raise ValueError(f'产品ID {product_id} 不属于当前商户')
quantities = _split_quantities(total_value, unit_size)
for quantity in quantities:
detail = models.StockChangeDetail.objects.create(
stock_change_record=stock_change_record,
product=product,
quantity=quantity,
merchant=merchant,
unit=product.unit,
)
created_details.append(detail)
if warehouse.merchant.auto_complete_stock_change:
make_stock_change_completed(stock_change_record)
logger.info('自动确认宽松模式库存变动记录 ID: %s', stock_change_record.id)
return stock_change_record, created_details, len(created_details)
def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None: def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None:
"""根据产品ID和仓库ID查找库存记录""" """根据产品ID和仓库ID查找库存记录"""

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import logging import logging
from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum, WareHouseModeEnum
from . import models, services from . import models, services
@@ -437,6 +437,191 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
source_id=self.purchase_order.id, source_id=self.purchase_order.id,
products=products, products=products,
) )
def test_strict_mode_requires_restrict_in(self):
"""严谨模式仅允许在严进宽出仓库中使用"""
self.warehouse_main.mode = WareHouseModeEnum.UNRESTRICTED
self.warehouse_main.save()
with self.assertRaises(ValueError) as ctx:
services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('10.00')]}],
)
self.assertIn('宽进宽出', str(ctx.exception))
def test_strict_mode_not_implemented_for_restrict_in_out(self):
"""严进严出模式暂不支持"""
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
with self.assertRaises(ValueError) as ctx:
services.create_stock_change_record_with_details(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('10.00')]}],
)
self.assertIn('严进严出', str(ctx.exception))
class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
"""测试宽松模式的库存变动创建"""
def setUp(self):
super().setUp()
self.warehouse_main.mode = WareHouseModeEnum.UNRESTRICTED
self.warehouse_main.save()
def test_relaxed_even_split(self):
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('100.00'), 'unit_count': Decimal('25.00')},
}
]
record, details, created_count = services.create_stock_change_record_relaxed(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=products,
)
self.assertEqual(created_count, 4)
self.assertEqual(record.details.count(), 4)
self.assertListEqual(
[detail.quantity for detail in details],
[Decimal('25.00')] * 4,
)
def test_relaxed_with_remainder(self):
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('105.00'), 'unit_count': Decimal('30.00')},
}
]
_, details, created_count = services.create_stock_change_record_relaxed(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=products,
)
self.assertEqual(created_count, 4)
self.assertListEqual(
[detail.quantity for detail in details],
[Decimal('30.00'), Decimal('30.00'), Decimal('30.00'), Decimal('15.00')],
)
def test_relaxed_invalid_unit_count(self):
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('50.00'), 'unit_count': Decimal('0')},
}
]
with self.assertRaises(ValueError):
services.create_stock_change_record_relaxed(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=products,
)
def test_relaxed_auto_complete_triggers_completion(self):
self.merchant.auto_complete_stock_change = True
self.merchant.save()
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('10.00'), 'unit_count': Decimal('2.00')},
}
]
with patch('stock.services.make_stock_change_completed') as mock_complete:
services.create_stock_change_record_relaxed(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=products,
)
mock_complete.assert_called_once()
def test_relaxed_mode_requires_unrestricted(self):
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN
self.warehouse_main.save()
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('10.00'), 'unit_count': Decimal('2.00')},
}
]
with self.assertRaises(ValueError) as ctx:
services.create_stock_change_record_relaxed(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=products,
)
self.assertIn('严进宽出', str(ctx.exception))
def test_relaxed_mode_not_implemented_for_restrict_in_out(self):
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
self.warehouse_main.save()
products = [
{
'product': self.product_fabric_a.id,
'quantity': {'value': Decimal('10.00'), 'unit_count': Decimal('2.00')},
}
]
with self.assertRaises(ValueError) as ctx:
services.create_stock_change_record_relaxed(
merchant=self.merchant,
created_by=None,
type=models.StockChangeTypeEnum.ADD,
warehouse_id=self.warehouse_main.id,
source_type=models.StockChangeSourceEnum.PURCHASE,
source_id=self.purchase_order.id,
products=products,
)
self.assertIn('严进严出', str(ctx.exception))
def test_complete_outbound_record(self): def test_complete_outbound_record(self):
"""测试完成出库记录""" """测试完成出库记录"""