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

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/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'),

View File

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

View File

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

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
#### 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:
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}'

View File

@@ -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查找库存记录"""