diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 292be57..ef3b826 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -27,7 +27,7 @@ class ShipmentSerializer(serializers.ModelSerializer): model = Shipment fields = [ 'id', 'merchant_id', 'merchant_name', - 'customer', 'customer_name', 'shipment_date', 'remark', + 'customer', 'customer_name', 'shipment_date', 'area', 'remark', 'status', 'status_display', 'external_id', 'cancelled_at', 'cancelled_by_id', 'cancelled_by_name', @@ -114,6 +114,7 @@ class ShipmentCreateNormalSerializer(serializers.Serializer): """ customer = serializers.IntegerField(help_text='客户ID') shipment_date = serializers.DateField(help_text='出货日期') + area = serializers.CharField(max_length=30, required=False, default='', allow_blank=True, help_text='出货地区(可选)') remark = serializers.CharField(required=False, default='', allow_blank=True, help_text='备注') sales_items = serializers.ListField( child=serializers.IntegerField(), @@ -144,6 +145,7 @@ class ShipmentCreateExternalSerializer(serializers.Serializer): """ customer = serializers.IntegerField(help_text='客户ID') shipment_date = serializers.DateField(help_text='出货日期') + area = serializers.CharField(max_length=30, required=False, default='', allow_blank=True, 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( @@ -164,6 +166,19 @@ class ShipmentCreateExternalSerializer(serializers.Serializer): return value +class ShipmentUpdateSerializer(serializers.Serializer): + """ + 出货单更新序列化器(PATCH/PUT) + + 说明:本次新增字段 area,需要保证更新入口可写入并回显。 + """ + customer = serializers.IntegerField(required=False, help_text='客户ID(可选)') + shipment_date = serializers.DateField(required=False, help_text='出货日期(可选)') + area = serializers.CharField(max_length=30, required=False, allow_blank=True, help_text='出货地区(可选)') + remark = serializers.CharField(required=False, allow_blank=True, help_text='备注(可选)') + external_id = serializers.CharField(max_length=120, required=False, allow_blank=True, allow_null=True, help_text='外部订单号(可选)') + + class SalesItemSerializer(serializers.Serializer): """ 销售品序列化器(只读) diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 836eb44..f4977c5 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -348,6 +348,7 @@ class ShipmentCreateAPITestCase(TestCase): data = { 'customer': self.customer.id, 'shipment_date': '2026-01-14', + 'area': '华东', 'remark': '测试备注', 'sales_items': [self.sales_item1.id, self.sales_item2.id] } @@ -362,6 +363,7 @@ class ShipmentCreateAPITestCase(TestCase): self.assertEqual(result['customer'], self.customer.id) self.assertEqual(result['customer_name'], self.customer.name) self.assertEqual(result['shipment_date'], '2026-01-14') + self.assertEqual(result.get('area', ''), '华东') self.assertEqual(result['remark'], '测试备注') self.assertEqual(result['items_count'], 2) self.assertEqual(result['created_by_id'], self.user.id) @@ -488,6 +490,7 @@ class ShipmentExternalCreateAPITestCase(TestCase): data = { 'customer': self.customer.id, 'shipment_date': '2026-01-14', + 'area': '华南', 'remark': 'external 备注', 'external_id': 'EXT-ORDER-001', 'external_finished_products': [ @@ -502,6 +505,7 @@ class ShipmentExternalCreateAPITestCase(TestCase): self.assertEqual(result['customer'], self.customer.id) self.assertEqual(result['external_id'], 'EXT-ORDER-001') + self.assertEqual(result.get('area', ''), '华南') self.assertEqual(result['items_count'], 0) self.assertEqual(result['external_finished_products_count'], 2) @@ -618,6 +622,7 @@ class ShipmentQueryAPITestCase(TestCase): shipment_date='2026-01-15', created_by=self.user1, remark='s1', + area='A1', ) self.shipment2 = shipment_models.Shipment.objects.create( merchant=self.merchant2, @@ -625,6 +630,7 @@ class ShipmentQueryAPITestCase(TestCase): shipment_date='2026-01-15', created_by=self.user2, remark='s2', + area='B1', ) self.client.force_authenticate(user=self.user1) @@ -643,6 +649,8 @@ class ShipmentQueryAPITestCase(TestCase): # 关键:列表序列化器必须稳定输出明细数组字段(即使为空) item = next(it for it in data['results'] if it['id'] == self.shipment1.id) + self.assertIn('area', item) + self.assertEqual(item['area'], 'A1') self.assertIn('sales_items', item) self.assertIsInstance(item['sales_items'], list) self.assertIn('external_finished_products', item) @@ -653,11 +661,30 @@ class ShipmentQueryAPITestCase(TestCase): self.assertEqual(resp.status_code, status.HTTP_200_OK) result = resp.json() self.assertEqual(result['id'], self.shipment1.id) + self.assertIn('area', result) + self.assertEqual(result['area'], 'A1') self.assertIn('sales_items', result) self.assertIsInstance(result['sales_items'], list) self.assertIn('external_finished_products', result) self.assertIsInstance(result['external_finished_products'], list) + def test_patch_shipment_area_success(self): + """ + 新增字段 area:支持更新(PATCH)并回显。 + """ + resp = self.client.patch( + f'/api/v1/shipment/shipments/{self.shipment1.id}/', + data={'area': '更新地区'}, + format='json' + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + result = resp.json() + self.assertEqual(result['id'], self.shipment1.id) + self.assertEqual(result.get('area', ''), '更新地区') + + self.shipment1.refresh_from_db() + self.assertEqual(getattr(self.shipment1, 'area', ''), '更新地区') + def test_list_shipments_includes_external_finished_product_remark(self): """ Shipments list 需要附带 external_finished_products 明细数据(包含 remark 字段)。 diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index 669b151..d1b386d 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -16,6 +16,7 @@ from .serializers import ( ShipmentSerializer, ShipmentCreateNormalSerializer, ShipmentCreateExternalSerializer, + ShipmentUpdateSerializer, ) @@ -122,6 +123,7 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): sales_item_ids=data['sales_items'], created_by=request.user, remark=data.get('remark', ''), + area=data.get('area', ''), ) except ValueError as e: return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -161,6 +163,47 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): def get(self, request, pk: int): return self.retrieve(request, pk=pk) + def patch(self, request, pk: int): + """ + 更新出货单(部分更新) + PATCH /api/v1/shipment/shipments// + """ + shipment = self.get_object() + serializer = ShipmentUpdateSerializer(data=request.data, partial=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + data = serializer.validated_data + + # customer 变更需校验 merchant 一致 + if 'customer' in data: + from basic_info.models import Customer + try: + customer = Customer.objects.get(id=data['customer']) + except Customer.DoesNotExist: + return Response({'detail': f'客户 {data["customer"]} 不存在'}, status=status.HTTP_400_BAD_REQUEST) + if customer.merchant_id != shipment.merchant_id: + return Response({'detail': '无权限绑定该客户'}, status=status.HTTP_400_BAD_REQUEST) + shipment.customer = customer + + if 'shipment_date' in data: + shipment.shipment_date = data['shipment_date'] + if 'area' in data: + shipment.area = (data.get('area') or '').strip() + if 'remark' in data: + shipment.remark = data.get('remark') or '' + if 'external_id' in data: + shipment.external_id = (data.get('external_id') or '').strip() or None + + shipment.save() + return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK) + + def put(self, request, pk: int): + """ + 更新出货单(全量更新,当前实现允许缺省字段,等同于 PATCH) + PUT /api/v1/shipment/shipments// + """ + return self.patch(request, pk=pk) + class ShipmentExternalCreateView(APIView): """ @@ -191,6 +234,7 @@ class ShipmentExternalCreateView(APIView): external_finished_products=data['external_finished_products'], created_by=request.user, remark=data.get('remark', ''), + area=data.get('area', ''), ) except ValueError as e: return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/docs/shipment_api.md b/docs/shipment_api.md index 8595cb0..a9b3696 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -84,6 +84,7 @@ |------|------|------|------| | customer | int | 是 | 客户ID | | shipment_date | string | 是 | 出货日期(YYYY-MM-DD) | +| area | string | 否 | 出货地区(可空字符串,长度<=30) | | remark | string | 否 | 备注 | | sales_items | array[int] | 否 | 要关联的销售品ID列表 | @@ -93,6 +94,7 @@ { "customer": 1, "shipment_date": "2026-01-14", + "area": "华东", "remark": "备注信息", "sales_items": [1, 2, 3] } @@ -108,6 +110,7 @@ "customer": 1, "customer_name": "客户A", "shipment_date": "2026-01-14", + "area": "华东", "remark": "备注信息", "items_count": 3, "sales_items": [], @@ -129,6 +132,7 @@ | customer | int | 客户ID | | customer_name | string | 客户名称 | | shipment_date | string | 出货日期 | +| area | string | 出货地区 | | remark | string | 备注 | | items_count | int | 关联的销售品数量 | | created_by_id | int | 创建人ID | @@ -180,6 +184,7 @@ |------|------|------|------| | customer | int | 是 | 客户ID | | shipment_date | string | 是 | 出货日期(YYYY-MM-DD) | +| area | string | 否 | 出货地区(可空字符串,长度<=30) | | remark | string | 否 | 备注 | | external_id | string | 是 | 外部订单号(长度<=120) | | external_finished_products | array[object] | 是 | 外部成品表结构数组(至少 1 条) | @@ -198,6 +203,7 @@ { "customer": 1, "shipment_date": "2026-01-14", + "area": "华南", "remark": "external 备注(可选)", "external_id": "EXT-ORDER-001", "external_finished_products": [ @@ -217,6 +223,7 @@ "customer": 1, "customer_name": "客户A", "shipment_date": "2026-01-14", + "area": "华南", "remark": "external 备注(可选)", "status": 1, "status_display": "待送货", diff --git a/shipment/migrations/0008_add_area_to_shipment.py b/shipment/migrations/0008_add_area_to_shipment.py new file mode 100644 index 0000000..88c7ca8 --- /dev/null +++ b/shipment/migrations/0008_add_area_to_shipment.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipment', '0007_add_remark_to_external_finished_product'), + ] + + operations = [ + migrations.AddField( + model_name='shipment', + name='area', + field=models.CharField(blank=True, default='', help_text='出货地区(可空字符串)', max_length=30, verbose_name='地区'), + ), + ] + diff --git a/shipment/models.py b/shipment/models.py index 73099e7..fcc2256 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -127,6 +127,14 @@ class Shipment(ModelBase): verbose_name='出货日期', help_text='实际出货日期' ) + + area = models.CharField( + max_length=30, + blank=True, + default='', + verbose_name='地区', + help_text='出货地区(可空字符串)', + ) remark = models.TextField( blank=True, diff --git a/shipment/services.py b/shipment/services.py index 596e0fd..c595ea2 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -49,6 +49,7 @@ def create_shipment( sales_item_ids: List[int], created_by, remark: str = '', + area: str = '', ) -> Shipment: """ 创建出货单并关联销售品 @@ -104,6 +105,7 @@ def create_shipment( merchant=merchant, customer=customer, shipment_date=shipment_date, + area=(area or '').strip(), remark=remark, created_by=created_by, ) @@ -125,6 +127,7 @@ def create_external_shipment( external_finished_products: List[dict], created_by, remark: str = '', + area: str = '', ) -> Shipment: """ 创建出货单(external 版),并批量写入外部成品表并关联到出货单。 @@ -160,6 +163,7 @@ def create_external_shipment( merchant=merchant, customer=customer, shipment_date=shipment_date, + area=(area or '').strip(), remark=remark, created_by=created_by, external_id=external_id,