1
0
forked from erp-dev/erp

feat: transfer api for stock module

This commit is contained in:
2025-12-02 14:14:17 +08:00
parent 9dbb2f9c9b
commit 0b8146d972
9 changed files with 616 additions and 0 deletions

View File

@@ -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): class UploadedFileSerializer(serializers.ModelSerializer):

View File

@@ -48,6 +48,7 @@ urlpatterns = [
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/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/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>/', 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>/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('stock-change/<int:record_id>/offset/', stock_change_views.offset_stock_change, name='offset_stock_change'),

View File

@@ -15,6 +15,7 @@ from .detail import GetStockChangeView
from .finish import FinishStockChangeView from .finish import FinishStockChangeView
from .settings import SetMerchantAutoCompleteView from .settings import SetMerchantAutoCompleteView
from .offset import OffsetStockChangeView from .offset import OffsetStockChangeView
from .transfer import StockTransferView
# 向后兼容:保持原有的函数式接口 # 向后兼容:保持原有的函数式接口
create_full_stock_change = CreateStockChangeView.as_view() create_full_stock_change = CreateStockChangeView.as_view()
@@ -26,6 +27,7 @@ get_stock_change = GetStockChangeView.as_view()
finish_stock_change = FinishStockChangeView.as_view() finish_stock_change = FinishStockChangeView.as_view()
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view() set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
offset_stock_change = OffsetStockChangeView.as_view() offset_stock_change = OffsetStockChangeView.as_view()
transfer_stock_change = StockTransferView.as_view()
__all__ = [ __all__ = [
'StockChangeViewMixin', 'StockChangeViewMixin',
@@ -38,6 +40,7 @@ __all__ = [
'FinishStockChangeView', 'FinishStockChangeView',
'SetMerchantAutoCompleteView', 'SetMerchantAutoCompleteView',
'OffsetStockChangeView', 'OffsetStockChangeView',
'StockTransferView',
'create_full_stock_change', 'create_full_stock_change',
'create_relaxed_stock_change', 'create_relaxed_stock_change',
'create_restrict_stock_change', 'create_restrict_stock_change',
@@ -47,4 +50,5 @@ __all__ = [
'finish_stock_change', 'finish_stock_change',
'set_merchant_auto_complete_stock_change', 'set_merchant_auto_complete_stock_change',
'offset_stock_change', 'offset_stock_change',
'transfer_stock_change',
] ]

View File

@@ -856,3 +856,89 @@ class OffsetStockChangeAPITestCase(TestCase):
response = self.client.post(self.offset_url, {}, format='json') response = self.client.post(self.offset_url, {}, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('reason', response.data) 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'])

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

View File

@@ -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 ### Other Related Endpoints
#### List Stock Changes #### List Stock Changes

View 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'),
),
]

View File

@@ -371,3 +371,134 @@ class StockFreeze(ModelBase):
class Meta: class Meta:
verbose_name = '库存冻结' verbose_name = '库存冻结'
verbose_name_plural = '库存冻结' 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}'

View File

@@ -678,6 +678,123 @@ class StockFlowService:
return payload 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: def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None:
"""根据产品ID和仓库ID查找库存记录""" """根据产品ID和仓库ID查找库存记录"""