diff --git a/api_v1/urls.py b/api_v1/urls.py index dbc178e..5ca50fd 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -25,7 +25,7 @@ from .views.products import ProductQuickViewSet from .views.parameters import StateParameterViewSet from .views.users import CreateUserWithProfileView from .views.mingdaoyun import MDYPlateOrderStagingViewSet -from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView +from .views.shipment import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() @@ -105,6 +105,11 @@ urlpatterns = [ ShipmentCreateView.as_view(), name='shipment_create' ), + path( + 'shipment/shipments/external/', + ShipmentExternalCreateView.as_view(), + name='shipment_create_external' + ), path( 'shipment/sales-items/by-printing-order//', SalesItemByPrintingOrderView.as_view(), diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py index 41f5ae1..a06e782 100644 --- a/api_v1/views/shipment/__init__.py +++ b/api_v1/views/shipment/__init__.py @@ -3,6 +3,6 @@ Shipment API 模块 提供出货单和销售品相关的 API 接口 """ -from .views import SalesItemByPrintingOrderView, ShipmentCreateView +from .views import SalesItemByPrintingOrderView, ShipmentCreateView, ShipmentExternalCreateView -__all__ = ['SalesItemByPrintingOrderView', 'ShipmentCreateView'] +__all__ = ['SalesItemByPrintingOrderView', 'ShipmentCreateView', 'ShipmentExternalCreateView'] diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 703a782..61aee28 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -14,12 +14,23 @@ class ShipmentSerializer(serializers.ModelSerializer): created_by_id = serializers.IntegerField(source='created_by.id', read_only=True, allow_null=True) created_by_name = serializers.SerializerMethodField() items_count = serializers.SerializerMethodField() + cancelled_by_id = serializers.IntegerField(source='cancelled_by.id', read_only=True, allow_null=True) + cancelled_by_name = serializers.SerializerMethodField() + status_display = serializers.CharField(source='get_status_display', read_only=True) + external_finished_products_count = serializers.SerializerMethodField() + merchant_id = serializers.IntegerField(source='merchant.id', read_only=True) + merchant_name = serializers.CharField(source='merchant.name', read_only=True) class Meta: model = Shipment fields = [ - 'id', 'customer', 'customer_name', 'shipment_date', 'remark', + 'id', 'merchant_id', 'merchant_name', + 'customer', 'customer_name', 'shipment_date', 'remark', + 'status', 'status_display', + 'external_id', + 'cancelled_at', 'cancelled_by_id', 'cancelled_by_name', 'items_count', 'created_by_id', 'created_by_name', + 'external_finished_products_count', 'created_at', 'updated_at' ] read_only_fields = ['id', 'created_at', 'updated_at'] @@ -35,10 +46,21 @@ class ShipmentSerializer(serializers.ModelSerializer): def get_items_count(self, obj): return obj.items.count() + def get_cancelled_by_name(self, obj): + if obj.cancelled_by: + employee = getattr(obj.cancelled_by, 'employee', None) + if employee: + return employee.name + return obj.cancelled_by.username + return None -class ShipmentCreateSerializer(serializers.Serializer): + def get_external_finished_products_count(self, obj): + return obj.external_finished_products.count() + + +class ShipmentCreateNormalSerializer(serializers.Serializer): """ - 出货单创建序列化器 + 出货单创建序列化器(普通版) """ customer = serializers.IntegerField(help_text='客户ID') shipment_date = serializers.DateField(help_text='出货日期') @@ -54,6 +76,42 @@ class ShipmentCreateSerializer(serializers.Serializer): # 去重 return list(set(value)) if value else [] +class ExternalFinishedProductInputSerializer(serializers.Serializer): + """外部成品表写入结构(external create 专用)""" + style_name = serializers.CharField(max_length=200) + num_of_rolls = serializers.IntegerField(min_value=0) + + +class ShipmentCreateExternalSerializer(serializers.Serializer): + """ + 出货单创建序列化器(external 版) + + 特点: + - 不绑定任何 SalesItem + - 必须提供 external_id + - 同时写入 ExternalFinishedProduct 列表并关联到 Shipment + """ + customer = serializers.IntegerField(help_text='客户ID') + shipment_date = serializers.DateField(help_text='出货日期') + remark = serializers.CharField(required=False, default='', allow_blank=True, help_text='备注') + external_id = serializers.CharField(max_length=120, help_text='外部订单号(必填)') + external_finished_products = serializers.ListField( + child=ExternalFinishedProductInputSerializer(), + required=True, + help_text='外部成品表结构数组(必填)' + ) + + def validate_external_id(self, value): + value = (value or '').strip() + if not value: + raise serializers.ValidationError('external_id 不能为空') + return value + + def validate_external_finished_products(self, value): + if not value: + raise serializers.ValidationError('external_finished_products 不能为空') + return value + class SalesItemSerializer(serializers.Serializer): """ diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index d2972a7..c9e1048 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -106,6 +106,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase): # 创建出货单 self.shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant, customer=self.customer, shipment_date='2026-01-14', created_by=self.user, @@ -113,6 +114,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase): # 创建销售品 - 未关联出货单 self.sales_item1 = shipment_models.SalesItem.objects.create( + merchant=self.merchant, name='销售品1', quantity=Decimal('50.00'), unit=shipment_models.UnitChoices.METER, @@ -121,6 +123,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase): ) self.sales_item2 = shipment_models.SalesItem.objects.create( + merchant=self.merchant, name='销售品2', quantity=Decimal('30.00'), unit=shipment_models.UnitChoices.METER, @@ -132,6 +135,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase): # 创建销售品 - 已关联出货单 self.sales_item3 = shipment_models.SalesItem.objects.create( + merchant=self.merchant, name='销售品3(已出货)', quantity=Decimal('100.00'), unit=shipment_models.UnitChoices.METER, @@ -142,6 +146,7 @@ class SalesItemByPrintingOrderAPITestCase(TestCase): # 创建与该订单无关的销售品 self.sales_item_other = shipment_models.SalesItem.objects.create( + merchant=self.merchant, name='其它销售品', quantity=Decimal('999.00'), unit=shipment_models.UnitChoices.PIECE, @@ -304,6 +309,7 @@ class ShipmentCreateAPITestCase(TestCase): # 创建销售品(未关联出货单) self.sales_item1 = shipment_models.SalesItem.objects.create( + merchant=self.merchant, name='销售品1', quantity=Decimal('50.00'), unit=shipment_models.UnitChoices.METER, @@ -311,6 +317,7 @@ class ShipmentCreateAPITestCase(TestCase): ) self.sales_item2 = shipment_models.SalesItem.objects.create( + merchant=self.merchant, name='销售品2', quantity=Decimal('30.00'), unit=shipment_models.UnitChoices.METER, @@ -319,11 +326,13 @@ class ShipmentCreateAPITestCase(TestCase): # 创建已关联出货单的销售品 self.existing_shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant, customer=self.customer, shipment_date='2026-01-13', created_by=self.user, ) self.sales_item_shipped = shipment_models.SalesItem.objects.create( + merchant=self.merchant, name='销售品3(已出货)', quantity=Decimal('100.00'), unit=shipment_models.UnitChoices.METER, @@ -426,3 +435,121 @@ class ShipmentCreateAPITestCase(TestCase): response = self.client.post('/api/v1/shipment/shipments/', data, format='json') self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class ShipmentExternalCreateAPITestCase(TestCase): + """测试创建出货单 external 版 API""" + + def setUp(self): + self.client = APIClient() + + # 创建商户 + self.merchant = basic_models.Merchant.objects.create( + name='测试印花厂', + type=basic_models.MerchantTypeEnum.FACTORY + ) + + # 创建用户 + self.user = User.objects.create_user( + username='testuser_ext', + password='testpass123', + email='test_ext@example.com' + ) + + # 创建员工并关联商户 + self.employee = basic_models.Employee.objects.create( + sys_user=self.user, + merchant=self.merchant, + name='测试员工Ext', + mobile='13800138002', + status=basic_models.EmployeeStatusEnum.ACTIVE + ) + + # 创建客户(需要同 merchant) + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='测试客户Ext', + mobile='13900139002', + area='测试地区Ext' + ) + + # 创建一个销售品(用于验证 external 版不会绑定任何销售品) + self.sales_item = shipment_models.SalesItem.objects.create( + merchant=self.merchant, + name='销售品-不应被绑定', + quantity=Decimal('10.00'), + unit=shipment_models.UnitChoices.METER, + created_by=self.user, + ) + + self.client.force_authenticate(user=self.user) + + def test_create_external_shipment_success(self): + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + 'remark': 'external 备注', + 'external_id': 'EXT-ORDER-001', + 'external_finished_products': [ + {'style_name': '款式A', 'num_of_rolls': 2}, + {'style_name': '款式B', 'num_of_rolls': 5}, + ] + } + + response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + result = response.json() + + self.assertEqual(result['customer'], self.customer.id) + self.assertEqual(result['external_id'], 'EXT-ORDER-001') + self.assertEqual(result['items_count'], 0) + self.assertEqual(result['external_finished_products_count'], 2) + + shipment_id = result['id'] + + # 验证外部成品表写入并关联 + self.assertEqual( + shipment_models.ExternalFinishedProduct.objects.filter(shipment_id=shipment_id).count(), + 2 + ) + + # 验证不会绑定任何销售品 + self.sales_item.refresh_from_db() + self.assertIsNone(self.sales_item.shipment_id) + + def test_create_external_shipment_external_id_required(self): + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + 'external_id': ' ', + 'external_finished_products': [ + {'style_name': '款式A', 'num_of_rolls': 1}, + ] + } + response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('external_id', response.json()) + + def test_create_external_shipment_products_required(self): + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + 'external_id': 'EXT-ORDER-002', + 'external_finished_products': [] + } + response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('external_finished_products', response.json()) + + def test_create_external_shipment_unauthenticated(self): + self.client.logout() + data = { + 'customer': self.customer.id, + 'shipment_date': '2026-01-14', + 'external_id': 'EXT-ORDER-003', + 'external_finished_products': [ + {'style_name': '款式A', 'num_of_rolls': 1}, + ] + } + response = self.client.post('/api/v1/shipment/shipments/external/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index a118634..93c0d62 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -6,7 +6,12 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from .serializers import SalesItemSerializer, ShipmentSerializer, ShipmentCreateSerializer +from .serializers import ( + SalesItemSerializer, + ShipmentSerializer, + ShipmentCreateNormalSerializer, + ShipmentCreateExternalSerializer, +) class ShipmentCreateView(APIView): @@ -41,7 +46,7 @@ class ShipmentCreateView(APIView): def post(self, request): # 验证请求数据 - serializer = ShipmentCreateSerializer(data=request.data) + serializer = ShipmentCreateNormalSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -65,6 +70,43 @@ class ShipmentCreateView(APIView): return Response(response_serializer.data, status=status.HTTP_201_CREATED) +class ShipmentExternalCreateView(APIView): + """ + 创建出货单(external 版) + + POST /api/v1/shipment/shipments/external/ + + 特点: + - external_id 必填 + - external_finished_products 必填(数组) + - 不绑定任何销售品 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ShipmentCreateExternalSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + data = serializer.validated_data + + from shipment.services import create_external_shipment + try: + shipment = create_external_shipment( + customer_id=data['customer'], + shipment_date=data['shipment_date'], + external_id=data['external_id'], + external_finished_products=data['external_finished_products'], + created_by=request.user, + remark=data.get('remark', ''), + ) + except ValueError as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + response_serializer = ShipmentSerializer(shipment) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + class SalesItemByPrintingOrderView(APIView): """ 通过生产订单查询销售品 diff --git a/docs/2026-01-14_summary.md b/docs/2026-01-14_summary.md index 8fe20a1..f6c327f 100644 --- a/docs/2026-01-14_summary.md +++ b/docs/2026-01-14_summary.md @@ -156,6 +156,39 @@ python manage.py backfill_merchant --- +### 9. Shipment 创建 API:普通版 & External 版 + +新增并完善了两个创建出货单接口: + +#### 普通版(绑定销售品) +- **接口**:`POST /api/v1/shipment/shipments/` +- **参数**:`sales_items: [销售品ID...]` +- **行为**:创建出货单并把销售品关联到该出货单 + +#### External 版(不绑定销售品 + 外部成品表批量写入) +- **接口**:`POST /api/v1/shipment/shipments/external/` +- **参数**: + - `external_id` 必填 + - `external_finished_products`(数组)必填,用于写入 `ExternalFinishedProduct` 并关联到 Shipment +- **行为**:创建出货单 + 批量创建外部成品表记录(不关联任何销售品) + +#### 代码位置 +- `shipment/services.py`: 新增 `create_external_shipment()` +- `api_v1/views/shipment/views.py`: 新增 `ShipmentExternalCreateView` +- `api_v1/views/shipment/serializers.py`: 新增 external 创建序列化器与响应字段扩展 +- `api_v1/urls.py`: 注册 external 创建路由 +- `api_v1/views/shipment/test_api.py`: 新增 external create 测试 + +#### merchant 强制归属(非空) +- 为 `Shipment` 与 `SalesItem` 增加 `merchant` 外键且不允许为空 +- 创建时从 `request.user.employee.merchant` 自动绑定,并校验 customer 同商户 +- 普通版在关联销售品时校验销售品同商户,避免跨商户关联 + +#### 文档 +- `docs/shipment_api.md`: 补充 external create API 文档 + +--- + ## 待办 --- diff --git a/docs/shipment_api.md b/docs/shipment_api.md index f0fdd1b..e7721e9 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -44,6 +44,8 @@ ```json { "id": 1, + "merchant_id": 1, + "merchant_name": "测试印花厂", "customer": 1, "customer_name": "客户A", "shipment_date": "2026-01-14", @@ -61,6 +63,8 @@ | 字段 | 类型 | 说明 | |------|------|------| | id | int | 出货单ID | +| merchant_id | int | 所属商户ID(从当前登录用户推导) | +| merchant_name | string | 所属商户名称(从当前登录用户推导) | | customer | int | 客户ID | | customer_name | string | 客户名称 | | shipment_date | string | 出货日期 | @@ -99,6 +103,94 @@ --- +## 创建出货单(external 版) + +创建出货单但**不绑定任何销售品**,同时写入并关联“外部成品表”(用于兼容外部遗留系统)。 + +### 接口信息 + +- **URL**: `/api/v1/shipment/shipments/external/` +- **Method**: `POST` +- **认证**: 需要登录(JWT Token) + +### 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| customer | int | 是 | 客户ID | +| shipment_date | string | 是 | 出货日期(YYYY-MM-DD) | +| remark | string | 否 | 备注 | +| external_id | string | 是 | 外部订单号(长度<=120) | +| external_finished_products | array[object] | 是 | 外部成品表结构数组(至少 1 条) | + +`external_finished_products` 每项结构: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| style_name | string | 是 | 款式名称 | +| num_of_rolls | int | 是 | 卷数 | + +### 请求示例 + +```json +{ + "customer": 1, + "shipment_date": "2026-01-14", + "remark": "external 备注(可选)", + "external_id": "EXT-ORDER-001", + "external_finished_products": [ + {"style_name": "款式A", "num_of_rolls": 2}, + {"style_name": "款式B", "num_of_rolls": 5} + ] +} +``` + +### 响应示例 + +```json +{ + "id": 1, + "merchant_id": 1, + "merchant_name": "测试印花厂", + "customer": 1, + "customer_name": "客户A", + "shipment_date": "2026-01-14", + "remark": "external 备注(可选)", + "status": 1, + "status_display": "待送货", + "external_id": "EXT-ORDER-001", + "cancelled_at": null, + "cancelled_by_id": null, + "cancelled_by_name": null, + "items_count": 0, + "external_finished_products_count": 2, + "created_by_id": 1, + "created_by_name": "张三", + "created_at": "2026-01-14T10:00:00Z", + "updated_at": "2026-01-14T10:00:00Z" +} +``` + +### 错误响应 + +#### 400 Bad Request - external_id 为空 + +```json +{ + "external_id": ["external_id 不能为空"] +} +``` + +#### 400 Bad Request - external_finished_products 为空 + +```json +{ + "external_finished_products": ["external_finished_products 不能为空"] +} +``` + +--- + ## 通过生产订单查询销售品 查询与指定生产订单(PrintingOrder)关联的所有销售品(SalesItem)。 diff --git a/printing/handlers.py b/printing/handlers.py index a844f82..e190264 100644 --- a/printing/handlers.py +++ b/printing/handlers.py @@ -114,8 +114,22 @@ def on_printing_job_process_completed(sender, **kwargs): try: with transaction.atomic(): + # SalesItem.merchant 不允许为空,优先取 printing_job.merchant + sales_merchant = getattr(printing_job, 'merchant', None) + if not sales_merchant and getattr(printing_job, 'printing_order', None): + sales_merchant = getattr(printing_job.printing_order, 'merchant', None) + if not sales_merchant and last_completed_by and hasattr(last_completed_by, 'employee'): + sales_merchant = getattr(last_completed_by.employee, 'merchant', None) + if not sales_merchant: + logger.warning( + f'PrintingJob #{printing_job.id} 流程完成,' + f'但无法确定 merchant,跳过销售品创建' + ) + return + sales_item = SalesItem.objects.create( shipment=None, # 暂不关联出货单,待后续分配 + merchant=sales_merchant, name=product_name, quantity=quantity, unit=sales_unit, diff --git a/shipment/migrations/0006_add_merchant_to_shipment_and_salesitem.py b/shipment/migrations/0006_add_merchant_to_shipment_and_salesitem.py new file mode 100644 index 0000000..591a6cd --- /dev/null +++ b/shipment/migrations/0006_add_merchant_to_shipment_and_salesitem.py @@ -0,0 +1,90 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def backfill_shipment_salesitem_merchant(apps, schema_editor): + """ + 为历史数据补齐 merchant(本项目当前声称未上线,但迁移要可重复执行且安全)。 + - Shipment.merchant: 从 customer.merchant 推导 + - SalesItem.merchant: + - 优先从 shipment.merchant 推导 + - 否则从 created_by.employee.merchant 推导(如果存在) + """ + Shipment = apps.get_model('shipment', 'Shipment') + SalesItem = apps.get_model('shipment', 'SalesItem') + + # Shipment: merchant = customer.merchant + for s in Shipment.objects.filter(merchant__isnull=True).select_related('customer'): + if s.customer_id: + s.merchant_id = s.customer.merchant_id + s.save(update_fields=['merchant']) + + # SalesItem: merchant = shipment.merchant else created_by.employee.merchant + # 注意:迁移态下 employee 关系可能不存在,需 try/except + for item in SalesItem.objects.filter(merchant__isnull=True).select_related('shipment', 'shipment__merchant', 'created_by'): + if item.shipment_id and getattr(item.shipment, 'merchant_id', None): + item.merchant_id = item.shipment.merchant_id + item.save(update_fields=['merchant']) + continue + + user = getattr(item, 'created_by', None) + emp = getattr(user, 'employee', None) if user else None + merchant_id = getattr(emp, 'merchant_id', None) if emp else None + if merchant_id: + item.merchant_id = merchant_id + item.save(update_fields=['merchant']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0013_alter_customer_options'), + ('shipment', '0005_fix_shipment_external_finished_product_relation'), + ] + + operations = [ + migrations.AddField( + model_name='shipment', + name='merchant', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='shipments', + to='basic_info.merchant', + verbose_name='所属商户', + ), + ), + migrations.AddField( + model_name='salesitem', + name='merchant', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='sales_items', + to='basic_info.merchant', + verbose_name='所属商户', + ), + ), + migrations.RunPython(backfill_shipment_salesitem_merchant, migrations.RunPython.noop), + migrations.AlterField( + model_name='shipment', + name='merchant', + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name='shipments', + to='basic_info.merchant', + verbose_name='所属商户', + ), + ), + migrations.AlterField( + model_name='salesitem', + name='merchant', + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name='sales_items', + to='basic_info.merchant', + verbose_name='所属商户', + ), + ), + ] + diff --git a/shipment/models.py b/shipment/models.py index 24f18b8..ecff951 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -103,6 +103,13 @@ class Shipment(ModelBase): help_text='用于兼容外部遗留系统的订单号(可空)', ) + merchant = models.ForeignKey( + basic_models.Merchant, + on_delete=models.PROTECT, + related_name='shipments', + verbose_name='所属商户', + ) + customer = models.ForeignKey( basic_models.Customer, on_delete=models.PROTECT, @@ -167,6 +174,13 @@ class SalesItem(ModelBase): verbose_name='出货单', help_text='为空表示待分配' ) + + merchant = models.ForeignKey( + basic_models.Merchant, + on_delete=models.PROTECT, + related_name='sales_items', + verbose_name='所属商户', + ) name = models.CharField( max_length=200, diff --git a/shipment/services.py b/shipment/services.py index 3bde80a..59c397f 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -8,7 +8,7 @@ from typing import List from django.db import transaction from django.db.models import QuerySet -from shipment.models import SalesItem, Shipment +from shipment.models import ExternalFinishedProduct, SalesItem, Shipment def get_sales_items_by_printing_order( @@ -73,6 +73,15 @@ def create_shipment( customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: raise ValueError(f'客户 {customer_id} 不存在') + + # merchant 隔离:必须能解析出当前用户 merchant + emp = getattr(created_by, 'employee', None) + merchant = getattr(emp, 'merchant', None) if emp else None + if not merchant: + # superuser 也必须绑定 merchant(避免产生无法隔离的数据) + raise ValueError('用户未关联商户,无法创建出货单') + if customer.merchant_id != merchant.id: + raise ValueError('无权限为该客户创建出货单') # 验证销售品 if sales_item_ids: @@ -92,6 +101,7 @@ def create_shipment( # 创建出货单 shipment = Shipment.objects.create( + merchant=merchant, customer=customer, shipment_date=shipment_date, remark=remark, @@ -100,6 +110,69 @@ def create_shipment( # 关联销售品 if sales_item_ids: - SalesItem.objects.filter(id__in=sales_item_ids).update(shipment=shipment) + updated = SalesItem.objects.filter(id__in=sales_item_ids, merchant=merchant).update(shipment=shipment) + if updated != len(sales_item_ids): + raise ValueError('存在不属于当前商户的销售品,无法关联到出货单') return shipment + + +@transaction.atomic +def create_external_shipment( + customer_id: int, + shipment_date, + external_id: str, + external_finished_products: List[dict], + created_by, + remark: str = '', +) -> Shipment: + """ + 创建出货单(external 版),并批量写入外部成品表并关联到出货单。 + + 特点: + - 不绑定任何 SalesItem + - external_id 必填 + - external_finished_products 必填(至少 1 条) + """ + from basic_info.models import Customer + + # 验证客户存在 + try: + customer = Customer.objects.get(id=customer_id) + except Customer.DoesNotExist: + raise ValueError(f'客户 {customer_id} 不存在') + + # merchant 隔离 + emp = getattr(created_by, 'employee', None) + merchant = getattr(emp, 'merchant', None) if emp else None + if not merchant: + raise ValueError('用户未关联商户,无法创建出货单') + if customer.merchant_id != merchant.id: + raise ValueError('无权限为该客户创建出货单') + + external_id = (external_id or '').strip() + if not external_id: + raise ValueError('external_id 不能为空') + if not external_finished_products: + raise ValueError('external_finished_products 不能为空') + + shipment = Shipment.objects.create( + merchant=merchant, + customer=customer, + shipment_date=shipment_date, + remark=remark, + created_by=created_by, + external_id=external_id, + ) + + objs = [] + for item in external_finished_products: + objs.append(ExternalFinishedProduct( + shipment=shipment, + style_name=item.get('style_name', ''), + num_of_rolls=item.get('num_of_rolls', 0), + created_by=created_by, + )) + + ExternalFinishedProduct.objects.bulk_create(objs) + return shipment