forked from erp-dev/erp
feat: transfer api for stock module
This commit is contained in:
@@ -154,6 +154,86 @@ class SetMerchantAutoCompleteStockChangeSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class StockTransferProductSerializer(serializers.Serializer):
|
||||
product_id = serializers.IntegerField()
|
||||
quantities = serializers.ListField(
|
||||
child=serializers.DecimalField(max_digits=10, decimal_places=2),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
)
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||
num_of_rolls = serializers.IntegerField(required=False)
|
||||
unit_count = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
|
||||
consume_detail_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
has_quantities = bool(attrs.get('quantities'))
|
||||
has_value = attrs.get('value') is not None
|
||||
has_consumes = bool(attrs.get('consume_detail_ids'))
|
||||
if not (has_quantities or has_value or has_consumes):
|
||||
raise serializers.ValidationError('必须提供 quantities、value 或 consume_detail_ids 之一')
|
||||
return attrs
|
||||
|
||||
|
||||
class StockTransferSerializer(serializers.Serializer):
|
||||
from_warehouse = serializers.IntegerField()
|
||||
to_warehouse = serializers.IntegerField()
|
||||
transfer_date = serializers.DateField(required=False)
|
||||
remarks = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=500)
|
||||
request_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=64)
|
||||
products = StockTransferProductSerializer(many=True)
|
||||
|
||||
def validate_products(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError('产品列表不能为空')
|
||||
return value
|
||||
|
||||
|
||||
class TransferOrderItemSerializer(serializers.ModelSerializer):
|
||||
product_name = serializers.CharField(source='product.name', read_only=True)
|
||||
unit_display = serializers.CharField(source='get_unit_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = stock_models.TransferOrderItem
|
||||
fields = ['id', 'product', 'product_name', 'total_quantity', 'unit', 'unit_display', 'num_of_rolls']
|
||||
|
||||
|
||||
class StockTransferResponseSerializer(serializers.ModelSerializer):
|
||||
items = TransferOrderItemSerializer(many=True, read_only=True)
|
||||
from_warehouse_name = serializers.CharField(source='from_warehouse.name', read_only=True)
|
||||
to_warehouse_name = serializers.CharField(source='to_warehouse.name', read_only=True)
|
||||
mode_display = serializers.CharField(source='get_mode_display', read_only=True)
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
outgoing_record_id = serializers.IntegerField(source='outgoing_record.id', read_only=True)
|
||||
incoming_record_id = serializers.IntegerField(source='incoming_record.id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = stock_models.TransferOrder
|
||||
fields = [
|
||||
'id',
|
||||
'merchant',
|
||||
'from_warehouse',
|
||||
'from_warehouse_name',
|
||||
'to_warehouse',
|
||||
'to_warehouse_name',
|
||||
'mode',
|
||||
'mode_display',
|
||||
'operator',
|
||||
'transfer_date',
|
||||
'status',
|
||||
'status_display',
|
||||
'remarks',
|
||||
'request_id',
|
||||
'outgoing_record_id',
|
||||
'incoming_record_id',
|
||||
'items',
|
||||
]
|
||||
|
||||
|
||||
# ==================== 通用文件上传序列化器 ====================
|
||||
|
||||
class UploadedFileSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -48,6 +48,7 @@ urlpatterns = [
|
||||
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/transfer/', stock_change_views.transfer_stock_change, name='stock_transfer'),
|
||||
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'),
|
||||
|
||||
@@ -15,6 +15,7 @@ from .detail import GetStockChangeView
|
||||
from .finish import FinishStockChangeView
|
||||
from .settings import SetMerchantAutoCompleteView
|
||||
from .offset import OffsetStockChangeView
|
||||
from .transfer import StockTransferView
|
||||
|
||||
# 向后兼容:保持原有的函数式接口
|
||||
create_full_stock_change = CreateStockChangeView.as_view()
|
||||
@@ -26,6 +27,7 @@ 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()
|
||||
transfer_stock_change = StockTransferView.as_view()
|
||||
|
||||
__all__ = [
|
||||
'StockChangeViewMixin',
|
||||
@@ -38,6 +40,7 @@ __all__ = [
|
||||
'FinishStockChangeView',
|
||||
'SetMerchantAutoCompleteView',
|
||||
'OffsetStockChangeView',
|
||||
'StockTransferView',
|
||||
'create_full_stock_change',
|
||||
'create_relaxed_stock_change',
|
||||
'create_restrict_stock_change',
|
||||
@@ -47,4 +50,5 @@ __all__ = [
|
||||
'finish_stock_change',
|
||||
'set_merchant_auto_complete_stock_change',
|
||||
'offset_stock_change',
|
||||
'transfer_stock_change',
|
||||
]
|
||||
|
||||
@@ -856,3 +856,89 @@ class OffsetStockChangeAPITestCase(TestCase):
|
||||
response = self.client.post(self.offset_url, {}, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('reason', response.data)
|
||||
|
||||
|
||||
class TransferStockChangeAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='调拨商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
category = basic_models.ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='调拨布料',
|
||||
product_prefix='TRN',
|
||||
)
|
||||
self.product = basic_models.Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='调拨面料',
|
||||
human_id='TRN-001',
|
||||
unit=basic_models.ProductUnitEnum.METER,
|
||||
)
|
||||
self.from_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='调出仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
self.to_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='调入仓',
|
||||
mode=basic_models.WareHouseModeEnum.RESTRICT_IN,
|
||||
type=basic_models.WarehouseTypeEnum.SCATTERED,
|
||||
)
|
||||
self.relaxed_warehouse = basic_models.WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='宽松仓',
|
||||
mode=basic_models.WareHouseModeEnum.UNRESTRICTED,
|
||||
)
|
||||
self.user = User.objects.create_user(username='transfer_user', password='pass123')
|
||||
self.employee = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='调拨员',
|
||||
mobile='13800138007',
|
||||
status=basic_models.EmployeeStatusEnum.ACTIVE,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_transfer_success(self):
|
||||
payload = {
|
||||
'from_warehouse': self.from_warehouse.id,
|
||||
'to_warehouse': self.to_warehouse.id,
|
||||
'products': [
|
||||
{
|
||||
'product_id': self.product.id,
|
||||
'quantities': ['10.50', '5.25'],
|
||||
}
|
||||
],
|
||||
'remarks': '整仓到散仓',
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/transfer/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['status'], stock_models.TransferOrderStatusEnum.COMPLETED)
|
||||
self.assertEqual(response.data['from_warehouse'], self.from_warehouse.id)
|
||||
self.assertEqual(response.data['to_warehouse'], self.to_warehouse.id)
|
||||
self.assertEqual(len(response.data['items']), 1)
|
||||
self.assertEqual(
|
||||
Decimal(response.data['items'][0]['total_quantity']),
|
||||
Decimal('15.75'),
|
||||
)
|
||||
|
||||
def test_transfer_requires_same_mode(self):
|
||||
payload = {
|
||||
'from_warehouse': self.from_warehouse.id,
|
||||
'to_warehouse': self.relaxed_warehouse.id,
|
||||
'products': [
|
||||
{
|
||||
'product_id': self.product.id,
|
||||
'quantities': ['8.00'],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = self.client.post('/api/v1/stock-change/transfer/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('模式一致', response.data['error'])
|
||||
|
||||
48
api_v1/views/stock_change_views/transfer.py
Normal file
48
api_v1/views/stock_change_views/transfer.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
|
||||
from rest_framework import status, views
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api_v1 import serializers
|
||||
from stock import services as stock_services
|
||||
|
||||
from .mixins import StockChangeViewMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockTransferView(StockChangeViewMixin, views.APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
if not self.check_employee_permission(request):
|
||||
return self.permission_error_response('无权限访问')
|
||||
|
||||
serializer = serializers.StockTransferSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
merchant = request.user.employee.merchant
|
||||
operator = request.user.employee
|
||||
|
||||
try:
|
||||
transfer_order = stock_services.create_transfer_order(
|
||||
merchant=merchant,
|
||||
created_by=request.user,
|
||||
operator=operator,
|
||||
from_warehouse_id=data['from_warehouse'],
|
||||
to_warehouse_id=data['to_warehouse'],
|
||||
items=data['products'],
|
||||
transfer_date=data.get('transfer_date'),
|
||||
remarks=data.get('remarks'),
|
||||
request_id=data.get('request_id') or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc:
|
||||
logger.error('创建调拨单失败: %s', exc, exc_info=True)
|
||||
return Response({'error': '创建调拨单失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
response_serializer = serializers.StockTransferResponseSerializer(transfer_order)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -302,6 +302,89 @@ Warehouses can operate in different modes:
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Stock Transfer (调拨)
|
||||
|
||||
**URL**: `POST /api/v1/stock-change/transfer/`
|
||||
|
||||
**Description**: Performs an in-transaction transfer between two warehouses that share the same warehouse **mode** (可以跨类型,例如整件仓 → 散件仓)。一次调用会同步创建调出与调入的 `StockChangeRecord` 并立即完成库存扣加,确保库存一致性。
|
||||
|
||||
**Business Constraints**:
|
||||
- `from_warehouse` 与 `to_warehouse` 必须属于当前商户,且不可相同。
|
||||
- 两个仓库的 `mode` 必须一致(RESTRICT_IN、UNRESTRICTED 均可)。目前不支持 `RESTRICT_IN_OUT`(严进严出)的调拨。
|
||||
- 请求在数据库事务内完成,不能依赖异步任务;支持 `request_id` 幂等重试。
|
||||
- `products` 列表沿用原出入库的 payload 结构,允许:
|
||||
- `quantities`: 严谨/严进模式的显式数量数组;
|
||||
- `value + unit_count/num_of_rolls`: 宽松模式的拆分定义;
|
||||
- `consume_detail_ids`: 若未来开放严进严出调拨,可在此补充(当前会拒绝)。
|
||||
|
||||
**Request Parameters**:
|
||||
```json
|
||||
{
|
||||
"from_warehouse": 3,
|
||||
"to_warehouse": 5,
|
||||
"transfer_date": "2025-12-02", // 可选,默认当天
|
||||
"remarks": "整件仓调拨到散件仓", // 可选
|
||||
"request_id": "transfer-20251202-001", // 可选幂等键
|
||||
"products": [
|
||||
{ "product_id": 12, "quantities": ["10.50", "5.25"] },
|
||||
{ "product_id": 27, "value": "120.0", "unit_count": "30" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": 88,
|
||||
"merchant": 6,
|
||||
"from_warehouse": 3,
|
||||
"from_warehouse_name": "整件一号仓",
|
||||
"to_warehouse": 5,
|
||||
"to_warehouse_name": "散件贵宾仓",
|
||||
"mode": 1,
|
||||
"mode_display": "严进宽出",
|
||||
"operator": 24,
|
||||
"transfer_date": "2025-12-02",
|
||||
"status": 2,
|
||||
"status_display": "已完成",
|
||||
"remarks": "整件仓调拨到散件仓",
|
||||
"request_id": "transfer-20251202-001",
|
||||
"outgoing_record_id": 512,
|
||||
"incoming_record_id": 513,
|
||||
"items": [
|
||||
{
|
||||
"id": 143,
|
||||
"product": 12,
|
||||
"product_name": "贡缎白坯布",
|
||||
"total_quantity": "15.75",
|
||||
"unit": 1,
|
||||
"unit_display": "米",
|
||||
"num_of_rolls": 2
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"product": 27,
|
||||
"product_name": "彩纱混纺",
|
||||
"total_quantity": "120.00",
|
||||
"unit": 1,
|
||||
"unit_display": "米",
|
||||
"num_of_rolls": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Cases**:
|
||||
| HTTP | Payload 示例 | 说明 |
|
||||
|------|--------------|------|
|
||||
| 400 | `{ "error": "调拨要求调出仓与调入仓的出入库模式一致" }` | 两仓 mode 不同 |
|
||||
| 400 | `{ "error": "调入仓与调出仓不能相同" }` | 相同仓库 |
|
||||
| 400 | `{ "error": "调出仓不存在或不属于当前商户" }` | 仓库不属于当前商户 |
|
||||
| 400 | `{ "error": "调拨产品明细不能为空" }` | products 为空 |
|
||||
| 409 | `{ "error": "请求重复" }` *(未来可扩展)* | 同一 `request_id` 重复提交(当前直接返回已存在记录) |
|
||||
|
||||
> **提示**:响应中附带 `outgoing_record_id` 与 `incoming_record_id`,可继续使用既有的 `GET /api/v1/stock-change/<id>/` 接口查看具体的明细与快照。
|
||||
|
||||
### Other Related Endpoints
|
||||
|
||||
#### List Stock Changes
|
||||
|
||||
66
stock/migrations/0007_transferorder.py
Normal file
66
stock/migrations/0007_transferorder.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-02 00:00
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0009_product_minimum_quantity'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('stock', '0006_remove_purchaseorder'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TransferOrder',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('mode', models.IntegerField(choices=[(1, '严进宽出'), (2, '严进严出'), (3, '宽进宽出')], verbose_name='调拨模式')),
|
||||
('transfer_date', models.DateField(verbose_name='调拨日期')),
|
||||
('status', models.IntegerField(choices=[(1, '待完成'), (2, '已完成'), (3, '已取消')], default=1, verbose_name='状态')),
|
||||
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
|
||||
('request_id', models.CharField(blank=True, max_length=64, null=True, verbose_name='请求ID')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_transfer_orders', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||
('from_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_transfer_orders', to='basic_info.warehouse', verbose_name='调出仓')),
|
||||
('incoming_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transfer_in_orders', to='stock.stockchangerecord', verbose_name='调入出入库记录')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfer_orders', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfer_orders', to='basic_info.employee', verbose_name='经办人')),
|
||||
('outgoing_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transfer_out_orders', to='stock.stockchangerecord', verbose_name='调出出入库记录')),
|
||||
('to_warehouse', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_transfer_orders', to='basic_info.warehouse', verbose_name='调入仓')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '调拨单',
|
||||
'verbose_name_plural': '调拨单',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TransferOrderItem',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('unit', models.IntegerField(choices=[(1, '米'), (2, '码'), (3, '公斤')], verbose_name='单位')),
|
||||
('total_quantity', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='数量合计')),
|
||||
('num_of_rolls', models.PositiveIntegerField(default=1, verbose_name='匹数')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfer_order_items', to='basic_info.product', verbose_name='产品')),
|
||||
('transfer_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='stock.transferorder', verbose_name='调拨单')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '调拨单明细',
|
||||
'verbose_name_plural': '调拨单明细',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='transferorder',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(request_id__isnull=False), fields=('merchant', 'request_id'), name='unique_transfer_request_per_merchant'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='transferorder',
|
||||
constraint=models.CheckConstraint(check=~models.Q(from_warehouse=models.F('to_warehouse')), name='transfer_different_warehouses'),
|
||||
),
|
||||
]
|
||||
131
stock/models.py
131
stock/models.py
@@ -371,3 +371,134 @@ class StockFreeze(ModelBase):
|
||||
class Meta:
|
||||
verbose_name = '库存冻结'
|
||||
verbose_name_plural = '库存冻结'
|
||||
|
||||
|
||||
class TransferOrderStatusEnum(models.IntegerChoices):
|
||||
DRAFT = 1, '待完成'
|
||||
COMPLETED = 2, '已完成'
|
||||
CANCELLED = 3, '已取消'
|
||||
|
||||
|
||||
class TransferOrder(ModelBase):
|
||||
"""跨仓调拨单"""
|
||||
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
basic_info_models.Merchant,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='transfer_orders',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
from_warehouse = models.ForeignKey(
|
||||
basic_info_models.WareHouse,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='outgoing_transfer_orders',
|
||||
verbose_name='调出仓',
|
||||
)
|
||||
to_warehouse = models.ForeignKey(
|
||||
basic_info_models.WareHouse,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='incoming_transfer_orders',
|
||||
verbose_name='调入仓',
|
||||
)
|
||||
mode = models.IntegerField(
|
||||
choices=basic_info_models.WareHouseModeEnum.choices,
|
||||
verbose_name='调拨模式',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
basic_info_models.Employee,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='transfer_orders',
|
||||
verbose_name='经办人',
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
'auth.User',
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_transfer_orders',
|
||||
verbose_name='创建人',
|
||||
)
|
||||
transfer_date = models.DateField(verbose_name='调拨日期')
|
||||
status = models.IntegerField(
|
||||
choices=TransferOrderStatusEnum.choices,
|
||||
default=TransferOrderStatusEnum.DRAFT,
|
||||
verbose_name='状态',
|
||||
)
|
||||
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
|
||||
request_id = models.CharField(max_length=64, blank=True, null=True, verbose_name='请求ID')
|
||||
outgoing_record = models.ForeignKey(
|
||||
'stock.StockChangeRecord',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='transfer_out_orders',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='调出出入库记录',
|
||||
)
|
||||
incoming_record = models.ForeignKey(
|
||||
'stock.StockChangeRecord',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='transfer_in_orders',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='调入出入库记录',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '调拨单'
|
||||
verbose_name_plural = '调拨单'
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['merchant', 'request_id'],
|
||||
condition=models.Q(request_id__isnull=False),
|
||||
name='unique_transfer_request_per_merchant',
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(from_warehouse=models.F('to_warehouse')),
|
||||
name='transfer_different_warehouses',
|
||||
),
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.from_warehouse_id and self.to_warehouse_id:
|
||||
if self.from_warehouse_id == self.to_warehouse_id:
|
||||
raise ValidationError('调拨的出入库仓库不能相同')
|
||||
if self.from_warehouse.merchant_id != self.merchant_id or self.to_warehouse.merchant_id != self.merchant_id:
|
||||
raise ValidationError('调拨仓库必须属于当前商户')
|
||||
if self.from_warehouse.mode != self.to_warehouse.mode:
|
||||
raise ValidationError('调拨要求出入库仓库的模式一致')
|
||||
if self.mode and self.mode != self.from_warehouse.mode:
|
||||
raise ValidationError('调拨模式与仓库模式不一致')
|
||||
|
||||
def __str__(self):
|
||||
return f'调拨单 {self.id} - {self.from_warehouse.name} -> {self.to_warehouse.name}'
|
||||
|
||||
|
||||
class TransferOrderItem(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
transfer_order = models.ForeignKey(
|
||||
TransferOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name='调拨单',
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
basic_info_models.Product,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='transfer_order_items',
|
||||
verbose_name='产品',
|
||||
)
|
||||
unit = models.IntegerField(
|
||||
choices=basic_info_models.ProductUnitEnum.choices,
|
||||
verbose_name='单位',
|
||||
)
|
||||
total_quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量合计')
|
||||
num_of_rolls = models.PositiveIntegerField(default=1, verbose_name='匹数')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '调拨单明细'
|
||||
verbose_name_plural = '调拨单明细'
|
||||
|
||||
def __str__(self):
|
||||
return f'调拨明细 {self.id} - 产品: {self.product.name}'
|
||||
|
||||
@@ -678,6 +678,123 @@ class StockFlowService:
|
||||
return payload
|
||||
|
||||
|
||||
def _aggregate_transfer_details(details: List[models.StockChangeDetail]) -> Dict[int, Dict[str, Any]]:
|
||||
aggregated: Dict[int, Dict[str, Any]] = {}
|
||||
for detail in details:
|
||||
product_id = detail.product_id
|
||||
if product_id not in aggregated:
|
||||
aggregated[product_id] = {
|
||||
'product': detail.product,
|
||||
'unit': detail.unit,
|
||||
'total': Decimal('0'),
|
||||
'num_of_rolls': 0,
|
||||
}
|
||||
aggregated[product_id]['total'] += Decimal(detail.quantity)
|
||||
aggregated[product_id]['num_of_rolls'] += 1
|
||||
return aggregated
|
||||
|
||||
|
||||
def create_transfer_order(
|
||||
*,
|
||||
merchant: basic_models.Merchant,
|
||||
created_by,
|
||||
operator: basic_models.Employee,
|
||||
from_warehouse_id: int,
|
||||
to_warehouse_id: int,
|
||||
items: List[Dict[str, Any]],
|
||||
transfer_date=None,
|
||||
remarks: str | None = '',
|
||||
request_id: str | None = None,
|
||||
) -> models.TransferOrder:
|
||||
if not merchant:
|
||||
raise ValueError('必须提供商户信息')
|
||||
if operator is None:
|
||||
raise ValueError('必须提供调拨经办人')
|
||||
if operator.merchant_id != merchant.id:
|
||||
raise ValueError('经办人不属于当前商户')
|
||||
if not items:
|
||||
raise ValueError('调拨产品明细不能为空')
|
||||
if not from_warehouse_id or not to_warehouse_id:
|
||||
raise ValueError('必须提供调出仓和调入仓')
|
||||
if from_warehouse_id == to_warehouse_id:
|
||||
raise ValueError('调入仓与调出仓不能相同')
|
||||
|
||||
transfer_date = transfer_date or timezone.now().date()
|
||||
|
||||
if request_id:
|
||||
existing = models.TransferOrder.objects.filter(merchant=merchant, request_id=request_id).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
try:
|
||||
from_warehouse = basic_models.WareHouse.objects.get(id=from_warehouse_id, merchant=merchant)
|
||||
except basic_models.WareHouse.DoesNotExist:
|
||||
raise ValueError('调出仓不存在或不属于当前商户')
|
||||
|
||||
try:
|
||||
to_warehouse = basic_models.WareHouse.objects.get(id=to_warehouse_id, merchant=merchant)
|
||||
except basic_models.WareHouse.DoesNotExist:
|
||||
raise ValueError('调入仓不存在或不属于当前商户')
|
||||
|
||||
if from_warehouse.mode != to_warehouse.mode:
|
||||
raise ValueError('调拨要求调出仓与调入仓的出入库模式一致')
|
||||
if from_warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
|
||||
raise ValueError('调拨暂不支持严进严出模式,请先转为其他模式')
|
||||
|
||||
flow_service = StockFlowService(merchant=merchant, created_by=created_by)
|
||||
|
||||
with transaction.atomic():
|
||||
transfer_order = models.TransferOrder.objects.create(
|
||||
merchant=merchant,
|
||||
from_warehouse=from_warehouse,
|
||||
to_warehouse=to_warehouse,
|
||||
operator=operator,
|
||||
created_by=created_by,
|
||||
transfer_date=transfer_date,
|
||||
status=models.TransferOrderStatusEnum.DRAFT,
|
||||
remarks=remarks or '',
|
||||
mode=from_warehouse.mode,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
out_record, out_details, _ = flow_service.stock_out(
|
||||
warehouse_id=from_warehouse.id,
|
||||
source_type=models.StockChangeSourceEnum.TRANSPORT_OUT,
|
||||
source_id=transfer_order.id,
|
||||
items=items,
|
||||
)
|
||||
in_record, _, _ = flow_service.stock_in(
|
||||
warehouse_id=to_warehouse.id,
|
||||
source_type=models.StockChangeSourceEnum.TRANSPORT_IN,
|
||||
source_id=transfer_order.id,
|
||||
items=items,
|
||||
)
|
||||
|
||||
make_stock_change_completed(out_record)
|
||||
make_stock_change_completed(in_record)
|
||||
|
||||
aggregated = _aggregate_transfer_details(out_details)
|
||||
transfer_items = [
|
||||
models.TransferOrderItem(
|
||||
transfer_order=transfer_order,
|
||||
product=data['product'],
|
||||
unit=data['unit'],
|
||||
total_quantity=data['total'],
|
||||
num_of_rolls=data['num_of_rolls'],
|
||||
)
|
||||
for data in aggregated.values()
|
||||
]
|
||||
if transfer_items:
|
||||
models.TransferOrderItem.objects.bulk_create(transfer_items)
|
||||
|
||||
transfer_order.outgoing_record = out_record
|
||||
transfer_order.incoming_record = in_record
|
||||
transfer_order.status = models.TransferOrderStatusEnum.COMPLETED
|
||||
transfer_order.save(update_fields=['outgoing_record', 'incoming_record', 'status'])
|
||||
|
||||
return transfer_order
|
||||
|
||||
|
||||
def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None:
|
||||
"""根据产品ID和仓库ID查找库存记录"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user