1
0
forked from erp-dev/erp

fear: stock reverse placeholder

This commit is contained in:
2025-11-30 13:40:47 +08:00
parent 4b3cd8eb75
commit f6f0ec2ae9
8 changed files with 554 additions and 31 deletions

View File

@@ -42,6 +42,8 @@ urlpatterns = [
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('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'),
path(
'set-merchant-auto-complete-stock-change/',
stock_change_views.set_merchant_auto_complete_stock_change,

View File

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

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

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

View File

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