From 0b8146d9722548c522259b8e9165f5f7e3d5e671 Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 2 Dec 2025 14:14:17 +0800 Subject: [PATCH] feat: transfer api for stock module --- api_v1/serializers.py | 80 +++++++++++ api_v1/urls.py | 1 + api_v1/views/stock_change_views/__init__.py | 4 + .../test_stock_change_api.py | 86 ++++++++++++ api_v1/views/stock_change_views/transfer.py | 48 +++++++ docs/stock_api_overview.md | 83 +++++++++++ stock/migrations/0007_transferorder.py | 66 +++++++++ stock/models.py | 131 ++++++++++++++++++ stock/services.py | 117 ++++++++++++++++ 9 files changed, 616 insertions(+) create mode 100644 api_v1/views/stock_change_views/transfer.py create mode 100644 stock/migrations/0007_transferorder.py diff --git a/api_v1/serializers.py b/api_v1/serializers.py index 4a3adbf..5189c2f 100644 --- a/api_v1/serializers.py +++ b/api_v1/serializers.py @@ -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): diff --git a/api_v1/urls.py b/api_v1/urls.py index 51c6452..d198130 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -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//', stock_change_views.get_stock_change, name='get_stock_change'), path('stock-change//finish/', stock_change_views.finish_stock_change, name='finish_stock_change'), path('stock-change//offset/', stock_change_views.offset_stock_change, name='offset_stock_change'), diff --git a/api_v1/views/stock_change_views/__init__.py b/api_v1/views/stock_change_views/__init__.py index 5cefe01..60eab65 100644 --- a/api_v1/views/stock_change_views/__init__.py +++ b/api_v1/views/stock_change_views/__init__.py @@ -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', ] diff --git a/api_v1/views/stock_change_views/test_stock_change_api.py b/api_v1/views/stock_change_views/test_stock_change_api.py index 0b43719..4e56d43 100644 --- a/api_v1/views/stock_change_views/test_stock_change_api.py +++ b/api_v1/views/stock_change_views/test_stock_change_api.py @@ -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']) diff --git a/api_v1/views/stock_change_views/transfer.py b/api_v1/views/stock_change_views/transfer.py new file mode 100644 index 0000000..0139438 --- /dev/null +++ b/api_v1/views/stock_change_views/transfer.py @@ -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) diff --git a/docs/stock_api_overview.md b/docs/stock_api_overview.md index 62978df..6d1bdc6 100644 --- a/docs/stock_api_overview.md +++ b/docs/stock_api_overview.md @@ -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//` 接口查看具体的明细与快照。 + ### Other Related Endpoints #### List Stock Changes diff --git a/stock/migrations/0007_transferorder.py b/stock/migrations/0007_transferorder.py new file mode 100644 index 0000000..5863991 --- /dev/null +++ b/stock/migrations/0007_transferorder.py @@ -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'), + ), + ] diff --git a/stock/models.py b/stock/models.py index 548c635..673f067 100644 --- a/stock/models.py +++ b/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}' diff --git a/stock/services.py b/stock/services.py index 34dc216..ca288e1 100644 --- a/stock/services.py +++ b/stock/services.py @@ -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查找库存记录"""