forked from erp-dev/erp
fear: stock reverse placeholder
This commit is contained in:
@@ -12,7 +12,9 @@ from .create import (
|
||||
)
|
||||
from .list import ListStockChangesView, ListStockChangeDetailsView
|
||||
from .detail import GetStockChangeView
|
||||
from .finish import FinishStockChangeView
|
||||
from .settings import SetMerchantAutoCompleteView
|
||||
from .offset import OffsetStockChangeView
|
||||
|
||||
# 向后兼容:保持原有的函数式接口
|
||||
create_full_stock_change = CreateStockChangeView.as_view()
|
||||
@@ -21,7 +23,9 @@ 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()
|
||||
finish_stock_change = FinishStockChangeView.as_view()
|
||||
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
|
||||
offset_stock_change = OffsetStockChangeView.as_view()
|
||||
|
||||
__all__ = [
|
||||
'StockChangeViewMixin',
|
||||
@@ -31,12 +35,16 @@ __all__ = [
|
||||
'ListStockChangesView',
|
||||
'ListStockChangeDetailsView',
|
||||
'GetStockChangeView',
|
||||
'FinishStockChangeView',
|
||||
'SetMerchantAutoCompleteView',
|
||||
'OffsetStockChangeView',
|
||||
'create_full_stock_change',
|
||||
'create_relaxed_stock_change',
|
||||
'create_restrict_stock_change',
|
||||
'list_stock_changes',
|
||||
'list_stock_change_details',
|
||||
'get_stock_change',
|
||||
'finish_stock_change',
|
||||
'set_merchant_auto_complete_stock_change',
|
||||
'offset_stock_change',
|
||||
]
|
||||
|
||||
84
api_v1/views/stock_change_views/finish.py
Normal file
84
api_v1/views/stock_change_views/finish.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
标记库存变动记录完成的视图
|
||||
"""
|
||||
|
||||
from rest_framework import status, views
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
import logging
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from stock import models as stock_models, services as stock_services
|
||||
|
||||
from .mixins import StockChangeViewMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FinishStockChangeView(StockChangeViewMixin, views.APIView):
|
||||
"""标记库存变动记录为完成"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=['完成出入库'],
|
||||
responses={200: dict},
|
||||
summary='手动完成库存变动记录',
|
||||
description='根据出入库单ID手动触发库存扣减、快照写入,并标记为完成'
|
||||
)
|
||||
def post(self, request, record_id: int):
|
||||
if not self.check_employee_permission(request):
|
||||
return self.permission_error_response('无权限操作')
|
||||
|
||||
merchant_id = self.get_merchant_id(request)
|
||||
if merchant_id is None:
|
||||
return self.permission_error_response('无权限操作')
|
||||
|
||||
try:
|
||||
record = stock_models.StockChangeRecord.objects.select_related(
|
||||
'warehouse', 'created_by'
|
||||
).get(id=record_id)
|
||||
except stock_models.StockChangeRecord.DoesNotExist:
|
||||
return self.not_found_response(f'库存变动记录ID {record_id} 不存在')
|
||||
|
||||
if record.merchant_id != merchant_id:
|
||||
return self.permission_error_response('无权操作该库存变动记录')
|
||||
|
||||
if not self.validate_warehouse_visibility(record.warehouse_id, request):
|
||||
return self.permission_error_response('该库存变动记录的仓库对当前用户不可见')
|
||||
|
||||
try:
|
||||
stock_services.make_stock_change_completed(record)
|
||||
record.refresh_from_db()
|
||||
|
||||
message = (
|
||||
'库存变动记录已标记为完成'
|
||||
if record.is_finished else
|
||||
'库存变动记录仍未完成,请检查明细'
|
||||
)
|
||||
status_flag = 'success' if record.is_finished else 'pending'
|
||||
|
||||
logger.info(
|
||||
'用户 %s 手动完成库存变动记录 %s(状态:%s)',
|
||||
request.user.username,
|
||||
record.id,
|
||||
status_flag,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'status': status_flag,
|
||||
'message': message,
|
||||
'stock_change_record': self.build_record_data(record),
|
||||
}, status=status.HTTP_200_OK)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'手动完成库存变动记录 %s 失败: %s',
|
||||
record_id,
|
||||
str(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
return Response({
|
||||
'error': '标记库存变动记录失败',
|
||||
'message': str(exc),
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
104
api_v1/views/stock_change_views/offset.py
Normal file
104
api_v1/views/stock_change_views/offset.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
库存红冲(对冲)占位视图
|
||||
"""
|
||||
|
||||
from rest_framework import status, views, serializers
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from drf_spectacular.utils import extend_schema, OpenApiResponse
|
||||
|
||||
from stock import models as stock_models, services as stock_services
|
||||
|
||||
from .mixins import StockChangeViewMixin
|
||||
|
||||
|
||||
class StockChangeOffsetSerializer(serializers.Serializer):
|
||||
"""红冲请求体验证"""
|
||||
|
||||
reason = serializers.CharField(max_length=255, allow_blank=False)
|
||||
items = serializers.ListField(
|
||||
child=serializers.DictField(),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
)
|
||||
request_id = serializers.CharField(required=False, allow_blank=True)
|
||||
extra_meta = serializers.JSONField(required=False)
|
||||
|
||||
|
||||
class OffsetStockChangeView(StockChangeViewMixin, views.APIView):
|
||||
"""针对既有出入库记录发起红冲的占位接口"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=['库存红冲'],
|
||||
summary='发起库存红冲(占位)',
|
||||
description=(
|
||||
'对指定库存变动记录创建红冲请求。当前仅提供占位实现,'
|
||||
'会返回 501 Not Implemented,待库存红冲逻辑完善后启用。'
|
||||
),
|
||||
request=StockChangeOffsetSerializer,
|
||||
responses={
|
||||
201: OpenApiResponse(
|
||||
description='红冲成功(未来实现后返回)'
|
||||
),
|
||||
400: OpenApiResponse(description='参数或业务校验失败'),
|
||||
403: OpenApiResponse(description='无权访问对应出入库记录'),
|
||||
404: OpenApiResponse(description='库存变动记录不存在'),
|
||||
501: OpenApiResponse(description='功能占位,尚未实现'),
|
||||
},
|
||||
)
|
||||
def post(self, request, record_id: int):
|
||||
if not self.check_employee_permission(request):
|
||||
return self.permission_error_response('无权限访问')
|
||||
|
||||
merchant = request.user.employee.merchant
|
||||
|
||||
try:
|
||||
stock_change_record = stock_models.StockChangeRecord.objects.select_related(
|
||||
'merchant', 'warehouse'
|
||||
).get(id=record_id)
|
||||
except stock_models.StockChangeRecord.DoesNotExist:
|
||||
return self.not_found_response(f'库存变动记录ID {record_id} 不存在')
|
||||
|
||||
if stock_change_record.merchant_id != merchant.id:
|
||||
return self.permission_error_response('无权访问该库存变动记录')
|
||||
|
||||
if not self.validate_warehouse_visibility(stock_change_record.warehouse_id, request):
|
||||
return self.permission_error_response('该库存变动记录的仓库对当前用户不可见')
|
||||
|
||||
serializer = StockChangeOffsetSerializer(data=request.data or {})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = serializer.validated_data
|
||||
|
||||
service = stock_services.StockFlowService(merchant=merchant, created_by=request.user)
|
||||
|
||||
try:
|
||||
offset_record, details, detail_count = service.offset_stock_change(
|
||||
source_record_id=stock_change_record.id,
|
||||
reason=payload['reason'],
|
||||
items=payload.get('items'),
|
||||
request_id=payload.get('request_id'),
|
||||
extra_meta=payload.get('extra_meta'),
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
return Response(
|
||||
{
|
||||
'error': 'stock_offset_not_ready',
|
||||
'message': str(exc),
|
||||
'record_id': stock_change_record.id,
|
||||
},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
response_data = {
|
||||
'status': 'success',
|
||||
'message': '红冲记录已创建',
|
||||
'stock_change_record': self.build_record_data(offset_record),
|
||||
'details': [self.build_detail_data(detail) for detail in details],
|
||||
'created_details_count': detail_count,
|
||||
}
|
||||
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -669,3 +669,188 @@ class ListStockChangeDetailsAPITestCase(TestCase):
|
||||
self.assertEqual(result['record_type'], stock_models.StockChangeTypeEnum.ADD)
|
||||
self.assertEqual(result['record_type_display'], '入库')
|
||||
|
||||
|
||||
class FinishStockChangeAPITestCase(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-010',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
self.warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='完成测试仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
|
||||
self.user = User.objects.create_user(username='finish_user', password='pass123')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='仓管员',
|
||||
mobile='13800138005',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.record, self.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=101,
|
||||
products=[{'product': self.product.id, 'quantity': [Decimal('15.00')]}],
|
||||
)
|
||||
self.finish_url = f'/api/v1/stock-change/{self.record.id}/finish/'
|
||||
|
||||
def test_finish_stock_change_success(self):
|
||||
response = self.client.post(self.finish_url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['status'], 'success')
|
||||
self.assertTrue(response.data['stock_change_record']['is_finished'])
|
||||
|
||||
self.record.refresh_from_db()
|
||||
self.assertTrue(self.record.is_finished)
|
||||
inventory = stock_models.Inventory.objects.get(
|
||||
product_id=self.product.id,
|
||||
warehouse_id=self.warehouse.id,
|
||||
)
|
||||
self.assertEqual(inventory.quantity, Decimal('15.00'))
|
||||
|
||||
def test_finish_stock_change_idempotent(self):
|
||||
# 完成一次
|
||||
self.client.post(self.finish_url, format='json')
|
||||
# 再次完成应仍然成功
|
||||
response = self.client.post(self.finish_url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['status'], 'success')
|
||||
|
||||
def test_finish_stock_change_requires_employee(self):
|
||||
user_without_employee = User.objects.create_user(username='finish_no_emp', password='pass123')
|
||||
self.client.force_authenticate(user=user_without_employee)
|
||||
|
||||
response = self.client.post(self.finish_url, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.data['error'], '无权限操作')
|
||||
|
||||
def test_finish_stock_change_not_found(self):
|
||||
response = self.client.post('/api/v1/stock-change/999999/finish/', format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn('不存在', response.data['error'])
|
||||
|
||||
def test_finish_stock_change_other_merchant_forbidden(self):
|
||||
other_merchant = basic_models.Merchant.objects.create(
|
||||
name='外部商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
other_category = basic_models.ProductCategory.objects.create(
|
||||
merchant=other_merchant,
|
||||
name='外部布料',
|
||||
product_prefix='OTH',
|
||||
)
|
||||
other_product = basic_models.Product.objects.create(
|
||||
merchant=other_merchant,
|
||||
category=other_category,
|
||||
name='外部面料',
|
||||
human_id='OTH-001',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
other_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=other_merchant,
|
||||
name='外部仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
|
||||
other_record, _, _ = stock_services.create_stock_change_record_with_details(
|
||||
merchant=other_merchant,
|
||||
created_by=None,
|
||||
type=stock_models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=other_warehouse.id,
|
||||
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=202,
|
||||
products=[{'product': other_product.id, 'quantity': [Decimal('5.00')]}],
|
||||
)
|
||||
|
||||
response = self.client.post(f'/api/v1/stock-change/{other_record.id}/finish/', format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertIn('无权操作', response.data['error'])
|
||||
|
||||
|
||||
class OffsetStockChangeAPITestCase(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='RED',
|
||||
)
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='红冲布料',
|
||||
human_id='RED-001',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
self.warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='红冲仓库',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
self.user = User.objects.create_user(username='offset_user', password='pass123')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='红冲仓管员',
|
||||
mobile='13800138006',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.record, self.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=303,
|
||||
products=[{'product': self.product.id, 'quantity': [Decimal('10.00')]}],
|
||||
)
|
||||
self.offset_url = f'/api/v1/stock-change/{self.record.id}/offset/'
|
||||
|
||||
def test_offset_stock_change_returns_not_implemented(self):
|
||||
payload = {
|
||||
'reason': '采购单误入库,需要红冲',
|
||||
'request_id': 'req-123',
|
||||
}
|
||||
response = self.client.post(self.offset_url, payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_501_NOT_IMPLEMENTED)
|
||||
self.assertEqual(response.data['error'], 'stock_offset_not_ready')
|
||||
self.assertEqual(response.data['record_id'], self.record.id)
|
||||
|
||||
def test_offset_stock_change_requires_reason(self):
|
||||
response = self.client.post(self.offset_url, {}, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('reason', response.data)
|
||||
|
||||
Reference in New Issue
Block a user