forked from erp-dev/erp
fear: stock reverse placeholder
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -359,6 +359,133 @@ Warehouses can operate in different modes:
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Stock Snapshot List
|
||||
|
||||
- **URL**: `GET /api/v1/stock-snapshots/`
|
||||
- **Description**: Read-only list of `StockSnapshot` entries. Supports `limit/offset` pagination,搜索与过滤。
|
||||
- **Query Parameters**:
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `product` / `product_name` / `product_code` | 指定产品或模糊搜索 |
|
||||
| `warehouse` / `warehouse_name` | 指定仓库或模糊搜索 |
|
||||
| `stock_change_record` | 关联的出入库记录 ID |
|
||||
| `unit` | 单位过滤 |
|
||||
| `cancelled` | `true/false`,是否被标记取消 |
|
||||
| `has_offset` | `true/false`,筛选是否存在冲抵记录 |
|
||||
| `date_from` / `date_to` | 创建时间范围(`YYYY-MM-DD`) |
|
||||
| `search` | 针对产品名、产品编码、仓库名的全文搜索 |
|
||||
| `ordering` | 排序字段,默认 `-created_at` |
|
||||
|
||||
**Response 示例**:
|
||||
```json
|
||||
{
|
||||
"count": 2,
|
||||
"results": [
|
||||
{
|
||||
"id": 901,
|
||||
"product": 15,
|
||||
"product_name": "纯棉印花布",
|
||||
"warehouse": 3,
|
||||
"warehouse_name": "严进仓",
|
||||
"delta": "120.00",
|
||||
"quantity_before": "80.00",
|
||||
"quantity_after": "200.00",
|
||||
"stock_change_record": 456,
|
||||
"stock_change_record_type": "入库",
|
||||
"stock_change_record_source": "采购",
|
||||
"unit": 1,
|
||||
"unit_display": "米",
|
||||
"num_of_rolls": 3,
|
||||
"offset_to": null,
|
||||
"cancelled": false,
|
||||
"created_at": "2025-11-25T12:05:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Merchant Auto Complete Setting
|
||||
|
||||
- **URL**: `POST /api/v1/set-merchant-auto-complete-stock-change/`
|
||||
- **Description**: 为当前商户开启 / 关闭“创建出入库记录后自动完成”功能。开启后,`create_*` 接口在事务内会立即调用 `stock.services.make_stock_change_completed`,自动写入库存与快照。
|
||||
- **Request**:
|
||||
```json
|
||||
{
|
||||
"auto_complete": true
|
||||
}
|
||||
```
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
只有具备员工身份的登录用户才可调用该接口。
|
||||
|
||||
### 6. Stock Change Completion Behavior
|
||||
|
||||
- **自动完成**:当商户开启 `auto_complete_stock_change` 时,任意模式的创建接口都会立即调用 `make_stock_change_completed`,更新 `Inventory` 并生成 `StockSnapshot`。
|
||||
- **手动完成**:当需要补处理或未开启自动完成时,可调用 `POST /api/v1/stock-change/<id>/finish/` 手动触发 `make_stock_change_completed`,也可直接在后台脚本里调用服务函数。
|
||||
- **状态字段**:`StockChangeRecord.is_finished` / `finished_at` 会在完成时标记,用于列表和详情接口判断记录是否已经生效。
|
||||
|
||||
### 7. Finish Stock Change Record (Manual)
|
||||
|
||||
- **URL**: `POST /api/v1/stock-change/<id>/finish/`
|
||||
- **Description**: 手动触发指定出入库记录的库存扣减与快照写入,适用于未开启自动完成或需要补处理的场景。仅当前商户下且仓库可见的员工可调用。
|
||||
- **Request**: 无需请求体,只需保证 URL 中的 `<id>` 为目标 `StockChangeRecord` 的 ID。
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "库存变动记录已标记为完成",
|
||||
"stock_change_record": {
|
||||
"id": 17,
|
||||
"type": 1,
|
||||
"warehouse": 1,
|
||||
"warehouse_name": "严谨仓",
|
||||
"source_type": 1,
|
||||
"source_type_display": "采购",
|
||||
"source_id": 123,
|
||||
"is_finished": true,
|
||||
"finished_at": "2025-11-25T12:30:00Z",
|
||||
"created_at": "2025-11-25T11:50:00Z",
|
||||
"created_by": "stock_admin",
|
||||
"remarks": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Stock Change Offset (Red Flush Placeholder)
|
||||
|
||||
- **URL**: `POST /api/v1/stock-change/<id>/offset/`
|
||||
- **Description**: 面向未来的库存红冲入口。当前仅提供占位实现,用于锁定最终接口形态;实际红冲逻辑尚未上线,因此调用会返回 `501 Not Implemented`,方便前端或上游业务在流程编排中预留节点。
|
||||
- **Request**:
|
||||
```json
|
||||
{
|
||||
"reason": "审批错误,需要冲销",
|
||||
"items": [
|
||||
{
|
||||
"product_id": 1,
|
||||
"quantities": ["50.00"]
|
||||
}
|
||||
],
|
||||
"request_id": "po-123-offset",
|
||||
"extra_meta": {
|
||||
"source": "purchase_order",
|
||||
"operator": "warehouse_admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Response(当前占位行为)**:
|
||||
```json
|
||||
{
|
||||
"error": "stock_offset_not_ready",
|
||||
"message": "库存红冲功能尚未实现",
|
||||
"record_id": 25
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Creating a Stock-in Record (Full Mode)
|
||||
@@ -419,6 +546,12 @@ curl -X POST http://example.com/api/v1/stock-change/restrict/ \
|
||||
}'
|
||||
```
|
||||
|
||||
### Example 4: Finish a Stock Change Record Manually
|
||||
```bash
|
||||
curl -X POST http://example.com/api/v1/stock-change/17/finish/ \
|
||||
-H "Authorization: Bearer your_token"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Responses
|
||||
@@ -381,6 +381,30 @@ class StockFlowService:
|
||||
products=products,
|
||||
)
|
||||
|
||||
def offset_stock_change(
|
||||
self,
|
||||
*,
|
||||
source_record_id: int,
|
||||
reason: str,
|
||||
items: List[Dict[str, Any]] | None = None,
|
||||
request_id: str | None = None,
|
||||
extra_meta: Dict[str, Any] | None = None,
|
||||
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]:
|
||||
"""
|
||||
创建针对既有库存变动记录的红冲(对冲)占位接口。
|
||||
|
||||
Args:
|
||||
source_record_id: 需要被红冲的库存变动记录 ID。
|
||||
reason: 红冲原因,必须明确记录以便审计。
|
||||
items: 可选的部分红冲明细(支持宽进/严进等结构);为空时表示全量对冲。
|
||||
request_id: 幂等键;同一 request_id 的请求应视为一次操作。
|
||||
extra_meta: 额外的上下文信息(触发来源、操作者备注等)。
|
||||
|
||||
Returns:
|
||||
新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。
|
||||
"""
|
||||
raise NotImplementedError('库存红冲功能尚未实现')
|
||||
|
||||
def _get_and_validate_warehouse(self, warehouse_id: int) -> basic_models.WareHouse:
|
||||
if not warehouse_id:
|
||||
raise ValueError('必须提供仓库ID')
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch, MagicMock
|
||||
import logging
|
||||
|
||||
from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum, WareHouseModeEnum
|
||||
from business.models import PurchaseOrder
|
||||
from basic_info.models import Product, WareHouse, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum, WareHouseModeEnum
|
||||
from . import models, services
|
||||
|
||||
|
||||
@@ -49,14 +47,6 @@ class StockServicesTestCase(TestCase):
|
||||
location="备用仓库地址"
|
||||
)
|
||||
|
||||
# 创建供应商
|
||||
cls.supplier = Supplier.objects.create(
|
||||
merchant=cls.merchant,
|
||||
name="测试供应商",
|
||||
contact="张三",
|
||||
mobile="13800138000"
|
||||
)
|
||||
|
||||
# 创建产品
|
||||
cls.product_fabric_a = Product.objects.create(
|
||||
merchant=cls.merchant,
|
||||
@@ -83,21 +73,14 @@ class StockServicesTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
"""每个测试方法执行前的设置"""
|
||||
# 创建采购单
|
||||
self.purchase_order = PurchaseOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
order_date=timezone.now().date(),
|
||||
total_amount=Decimal('1000.00'),
|
||||
remarks="测试采购单"
|
||||
)
|
||||
self.purchase_source_id = 1001
|
||||
|
||||
# 创建库存变动记录(入库)
|
||||
self.stock_change_in = models.StockChangeRecord.objects.create(
|
||||
merchant=self.merchant,
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
warehouse=self.warehouse_main,
|
||||
is_finished=False
|
||||
)
|
||||
@@ -392,7 +375,7 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=self.products_payload,
|
||||
)
|
||||
|
||||
@@ -418,7 +401,7 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=self.products_payload,
|
||||
)
|
||||
|
||||
@@ -435,7 +418,7 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=products,
|
||||
)
|
||||
|
||||
@@ -451,7 +434,7 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('10.00')]}],
|
||||
)
|
||||
|
||||
@@ -468,7 +451,7 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('5.00'), Decimal('6.00')]}],
|
||||
)
|
||||
|
||||
@@ -604,7 +587,7 @@ class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=products,
|
||||
)
|
||||
|
||||
@@ -629,7 +612,7 @@ class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=products,
|
||||
)
|
||||
|
||||
@@ -654,7 +637,7 @@ class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=products,
|
||||
)
|
||||
|
||||
@@ -676,7 +659,7 @@ class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=products,
|
||||
)
|
||||
|
||||
@@ -700,7 +683,7 @@ class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=products,
|
||||
)
|
||||
|
||||
@@ -724,7 +707,7 @@ class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
source_id=self.purchase_source_id,
|
||||
products=products,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user