From 9afbb7379980b1312dfb32eb002bee5c585f759c Mon Sep 17 00:00:00 2001 From: colaftc Date: Mon, 6 Apr 2026 14:49:02 +0800 Subject: [PATCH] feat: salesitem can change/soft delete --- api_v1/tasks.py | 1 + api_v1/test_external_printing_records_sync.py | 30 ++ api_v1/urls.py | 6 + api_v1/views/shipment/__init__.py | 2 + api_v1/views/shipment/serializers.py | 43 ++- api_v1/views/shipment/test_api.py | 269 ++++++++++++++++++ api_v1/views/shipment/views.py | 133 ++++++++- ...inting_external_records_sync_2026-03-20.md | 2 + docs/sales_item_delete_api.md | 164 +++++++++++ docs/shipment_api.md | 2 + docs/shipment_status_api.md | 132 +++++++++ shipment/admin.py | 40 ++- .../migrations/0017_salesitemchangerecord.py | 59 ++++ .../0018_salesitem_soft_delete_fields.py | 40 +++ shipment/models.py | 67 +++++ shipment/services.py | 95 ++++++- 16 files changed, 1075 insertions(+), 10 deletions(-) create mode 100644 docs/sales_item_delete_api.md create mode 100644 docs/shipment_status_api.md create mode 100644 shipment/migrations/0017_salesitemchangerecord.py create mode 100644 shipment/migrations/0018_salesitem_soft_delete_fields.py diff --git a/api_v1/tasks.py b/api_v1/tasks.py index 3521a1b..c5a4683 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -554,6 +554,7 @@ def _build_external_order_data( 'customer': customer, 'fabric': str(first.get('HpName') or '').strip(), 'width': str(first.get('SeHao') or '').strip(), + 'area': str(first.get('area') or '').strip(), 'fabric_source': fabric_source or None, 'craft': craft or None, 'rolling_warn': str(first.get('BeiZhu') or '').strip() or None, diff --git a/api_v1/test_external_printing_records_sync.py b/api_v1/test_external_printing_records_sync.py index 0d068e8..9eba9da 100644 --- a/api_v1/test_external_printing_records_sync.py +++ b/api_v1/test_external_printing_records_sync.py @@ -86,6 +86,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): 'ShuLiang': '10.00', 'ShuLiangZ': '2.68', 'YanSe': product_name, + 'area': '周边', 'customer': { 'KhID': 'KH00999', 'KhName': '鸿烨服饰', @@ -159,6 +160,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): self.assertEqual(job.product, product) self.assertEqual(job.printing_order.position, r'\\fw\\2026年-LWQ15\\2026\\H鸿烨\\Tj1712#') self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定') + self.assertEqual(job.printing_order.area, '周边') @patch('api_v1.tasks._advance_external_printing_cursor') @patch('api_v1.tasks._fetch_external_product_image') @@ -186,6 +188,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): order = printing_models.PrintingOrder.objects.get(external_order_id='KD20432358') self.assertEqual(order.position, r'\\fw\\path\\first') self.assertEqual(order.fabric, '120克本白四面弹单定') + self.assertEqual(order.area, '周边') self.assertEqual(printing_models.PrintingJob.objects.count(), 1) @patch('api_v1.tasks._advance_external_printing_cursor') @@ -355,3 +358,30 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): self.assertEqual(job.size, '3.15') self.assertEqual(job.pieces, 20) self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定') + + @patch('api_v1.tasks._advance_external_printing_cursor') + @patch('api_v1.tasks._fetch_external_product_image') + @patch('api_v1.tasks._fetch_external_printing_records') + def test_order_area_updates_and_defaults_to_empty_string( + self, + mock_fetch_records, + mock_fetch_image, + mock_advance_cursor, + ): + initial_record = self._build_record(record_id=1000012, product_name=self.existing_product.name) + updated_record = self._build_record(record_id=1000013, product_name=self.existing_product.name) + updated_record['area'] = '' + mock_advance_cursor.return_value = {'updated': True} + + mock_fetch_records.return_value = self._build_payload([initial_record]) + first_result = sync_external_printing_records.run(limit=100) + + mock_fetch_records.return_value = self._build_payload([updated_record]) + second_result = sync_external_printing_records.run(limit=100) + + self.assertEqual(first_result['orders_created'], 1) + self.assertEqual(second_result['orders_updated'], 1) + self.assertEqual(mock_fetch_image.call_count, 0) + + order = printing_models.PrintingOrder.objects.get(external_order_id='KD20432358') + self.assertEqual(order.area, '') diff --git a/api_v1/urls.py b/api_v1/urls.py index 000e1dc..8cb229d 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -37,6 +37,7 @@ from .views.shipment import ( SalesItemByCustomerView, SalesItemByPrintingOrderView, SalesItemCreateView, + ShipmentStatusUpdateView, ShipmentDeliveryBindShipmentsView, ShipmentDeliveryCancelView, ShipmentDeliveryDetailView, @@ -308,6 +309,11 @@ urlpatterns = [ ShipmentDetailView.as_view(), name="shipment_detail", ), + path( + "shipment/shipments//status/", + ShipmentStatusUpdateView.as_view(), + name="shipment_status_update", + ), path( "shipment/shipments/external/", ShipmentExternalCreateView.as_view(), diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py index dc02b06..56c0b3a 100644 --- a/api_v1/views/shipment/__init__.py +++ b/api_v1/views/shipment/__init__.py @@ -8,6 +8,7 @@ from .views import ( SalesItemByCustomerView, SalesItemByPrintingOrderView, SalesItemCreateView, + ShipmentStatusUpdateView, ShipmentDeliveryBindShipmentsView, ShipmentDeliveryDetailView, ShipmentDeliveryListCreateView, @@ -24,6 +25,7 @@ __all__ = [ 'SalesItemByCustomerView', 'SalesItemByPrintingOrderView', 'SalesItemCreateView', + 'ShipmentStatusUpdateView', 'ShipmentDeliveryBindShipmentsView', 'ShipmentDeliveryDetailView', 'ShipmentDeliveryListCreateView', diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 6c8e784..bf79271 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -8,6 +8,7 @@ from shipment.models import ( ExternalFinishedProduct, SalesItem, Shipment, + ShipmentStatus, ShipmentDelivery, ShipmentDeliveryStatus, ) @@ -129,7 +130,7 @@ class ShipmentSerializer(serializers.ModelSerializer): return None def get_items_count(self, obj): - return obj.items.count() + return obj.items.filter(delete_at__isnull=True).count() def get_cancelled_by_name(self, obj): if obj.cancelled_by: @@ -156,7 +157,7 @@ class ShipmentSerializer(serializers.ModelSerializer): """ # 优先使用 prefetch 的 related manager;兜底为 none() rel = getattr(obj, "items", None) - items = list(rel.all()) if rel is not None else [] + items = list(rel.filter(delete_at__isnull=True)) if rel is not None else [] serializer_context = dict(self.context) serializer_context.update(_build_nested_sales_item_context(items)) return SalesItemDetailSerializer( @@ -361,6 +362,13 @@ class ShipmentUpdateSerializer(serializers.Serializer): ) +class ShipmentStatusUpdateSerializer(serializers.Serializer): + status = serializers.ChoiceField( + choices=ShipmentStatus.choices, + help_text="出货单状态(1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核)", + ) + + class SalesItemSerializer(serializers.Serializer): """ 销售品序列化器(只读) @@ -727,3 +735,34 @@ class SalesItemCreateSerializer(serializers.Serializer): remark=validated_data.get("remark", ""), position=validated_data.get("position", ""), ) + + +class SalesItemUpdateSerializer(serializers.Serializer): + """ + 销售品更新序列化器。 + + 当前仅开放数量、备注、货位的修改。 + """ + + quantity = serializers.CharField( + max_length=20, + required=False, + help_text="数量(可选,支持小数)", + ) + remark = serializers.CharField( + max_length=200, + required=False, + allow_blank=True, + help_text="备注(可选)", + ) + position = serializers.CharField( + max_length=200, + required=False, + allow_blank=True, + help_text="货位(可选)", + ) + + def validate(self, attrs): + if not attrs: + raise serializers.ValidationError("至少提供一个可修改字段") + return attrs diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index af51ab7..44a1f5e 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -784,6 +784,115 @@ class SalesItemDetailAPITestCase(APITestCase): self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + def test_patch_sales_item_success_and_create_change_record(self): + resp = self.client.patch( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/", + { + "quantity": "99.50", + "remark": "改备注", + "position": "C3-08", + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.sales_item.refresh_from_db() + self.assertEqual(self.sales_item.quantity, Decimal("99.50")) + self.assertEqual(self.sales_item.remark, "改备注") + self.assertEqual(self.sales_item.position, "C3-08") + + change_record = self.sales_item.change_records.get() + self.assertEqual(change_record.operator_id, self.user.id) + self.assertEqual( + change_record.before_values, + { + "quantity": "88.00", + "remark": "", + "position": "B2-03", + }, + ) + self.assertEqual( + change_record.after_values, + { + "quantity": "99.50", + "remark": "改备注", + "position": "C3-08", + }, + ) + + def test_patch_sales_item_rejects_relation_or_other_disallowed_fields(self): + resp = self.client.patch( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/", + { + "name": "不允许改名", + "customer_id": self.customer.id, + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("仅允许修改以下字段", resp.json()["detail"]) + self.assertFalse(self.sales_item.change_records.exists()) + + def test_patch_sales_item_invalid_quantity(self): + resp = self.client.patch( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/", + { + "quantity": "abc", + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("数量 abc 格式无效", resp.json()["detail"]) + self.assertFalse(self.sales_item.change_records.exists()) + + def test_patch_sales_item_other_merchant_404(self): + self.client.force_authenticate(user=self.other_user) + + resp = self.client.patch( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/", + { + "remark": "无权限修改", + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_sales_item_requires_permission(self): + resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/") + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.sales_item.refresh_from_db() + self.assertIsNone(self.sales_item.delete_at) + self.assertIsNone(self.sales_item.delete_by) + + def test_delete_sales_item_success_soft_deletes_and_hides_detail(self): + permission = Permission.objects.get(codename="soft_delete_salesitem") + self.user.user_permissions.add(permission) + + resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/") + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.json()["detail"], "销售品已标记为删除") + + self.sales_item.refresh_from_db() + self.assertIsNotNone(self.sales_item.delete_at) + self.assertEqual(self.sales_item.delete_by, self.user) + + detail_resp = self.client.get(f"/api/v1/shipment/sales-items/{self.sales_item.id}/") + self.assertEqual(detail_resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_sales_item_other_merchant_404(self): + permission = Permission.objects.get(codename="soft_delete_salesitem") + self.other_user.user_permissions.add(permission) + self.client.force_authenticate(user=self.other_user) + + resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/") + + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + class ShipmentCreateAPITestCase(TestCase): """测试创建出货单 API""" @@ -1683,6 +1792,166 @@ class ShipmentStatusServiceTestCase(TestCase): ) +class ShipmentStatusAPITestCase(APITestCase): + """测试出货单状态流转 API""" + + def setUp(self): + self.client = APIClient() + + self.merchant = basic_models.Merchant.objects.create( + name="状态 API 商户", type=basic_models.MerchantTypeEnum.FACTORY + ) + self.other_merchant = basic_models.Merchant.objects.create( + name="状态 API 其它商户", type=basic_models.MerchantTypeEnum.FACTORY + ) + + self.user = User.objects.create_user( + username="shipment_status_api_user", + password="testpass123", + email="shipment_status_api@example.com", + ) + self.employee = basic_models.Employee.objects.create( + sys_user=self.user, + merchant=self.merchant, + name="状态 API 员工", + mobile="13800138101", + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + + self.other_user = User.objects.create_user( + username="shipment_status_api_other_user", + password="testpass123", + email="shipment_status_api_other@example.com", + ) + self.other_employee = basic_models.Employee.objects.create( + sys_user=self.other_user, + merchant=self.other_merchant, + name="状态 API 其它员工", + mobile="13800138102", + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name="状态 API 客户", + mobile="13900139101", + area="杭州", + ) + self.shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date="2026-04-02", + created_by=self.user, + ) + + self.client.force_authenticate(user=self.user) + + def test_patch_status_draft_to_published_success(self): + resp = self.client.patch( + f"/api/v1/shipment/shipments/{self.shipment.id}/status/", + {"status": shipment_models.ShipmentStatus.PUBLISHED}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.shipment.refresh_from_db() + self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.PUBLISHED) + self.assertIsNotNone(self.shipment.status_modified_at) + self.assertIsNone(self.shipment.approved_by) + self.assertIsNone(self.shipment.cancelled_by) + + def test_put_status_published_to_approved_sets_approved_by(self): + from shipment.services import modify_status + + modify_status( + self.shipment, + target_status=shipment_models.ShipmentStatus.PUBLISHED, + operator=self.user, + ) + + resp = self.client.put( + f"/api/v1/shipment/shipments/{self.shipment.id}/status/", + {"status": shipment_models.ShipmentStatus.APPROVED}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.shipment.refresh_from_db() + self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.APPROVED) + self.assertEqual(self.shipment.approved_by, self.user) + + def test_patch_status_published_to_rejected_success(self): + from shipment.services import modify_status + + modify_status( + self.shipment, + target_status=shipment_models.ShipmentStatus.PUBLISHED, + operator=self.user, + ) + + resp = self.client.patch( + f"/api/v1/shipment/shipments/{self.shipment.id}/status/", + {"status": shipment_models.ShipmentStatus.REJECTED}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.shipment.refresh_from_db() + self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.REJECTED) + + def test_patch_status_to_cancelled_sets_cancelled_by(self): + from shipment.services import modify_status + + modify_status( + self.shipment, + target_status=shipment_models.ShipmentStatus.PUBLISHED, + operator=self.user, + ) + + resp = self.client.patch( + f"/api/v1/shipment/shipments/{self.shipment.id}/status/", + {"status": shipment_models.ShipmentStatus.CANCELLED}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.shipment.refresh_from_db() + self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.CANCELLED) + self.assertEqual(self.shipment.cancelled_by, self.user) + + def test_patch_status_rejects_invalid_transition(self): + resp = self.client.patch( + f"/api/v1/shipment/shipments/{self.shipment.id}/status/", + {"status": shipment_models.ShipmentStatus.APPROVED}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("不允许将出货单状态从 草稿(未发布) 修改为 已审核", resp.json()["detail"]) + + def test_patch_status_other_merchant_404(self): + self.client.force_authenticate(user=self.other_user) + + resp = self.client.patch( + f"/api/v1/shipment/shipments/{self.shipment.id}/status/", + {"status": shipment_models.ShipmentStatus.PUBLISHED}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_patch_status_unauthenticated(self): + self.client.logout() + + resp = self.client.patch( + f"/api/v1/shipment/shipments/{self.shipment.id}/status/", + {"status": shipment_models.ShipmentStatus.PUBLISHED}, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) + + class SalesItemCreateAPITestCase(APITestCase): """销售品创建 API 测试""" diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index daeec38..253c184 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -15,6 +15,8 @@ from shipment.models import Shipment, ShipmentDelivery from .serializers import ( SalesItemDetailSerializer, SalesItemSerializer, + SalesItemUpdateSerializer, + ShipmentStatusUpdateSerializer, ShipmentDeliveryBindShipmentsSerializer, ShipmentDeliveryCreateSerializer, ShipmentDeliverySerializer, @@ -274,6 +276,69 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView): return self.patch(request, pk=pk) +class ShipmentStatusUpdateView(APIView): + """ + 更新出货单状态 + + PATCH /api/v1/shipment/shipments//status/ + PUT /api/v1/shipment/shipments//status/ + """ + + permission_classes = [IsAuthenticated] + + def get_queryset(self): + qs = Shipment.objects.all().select_related( + "merchant", + "customer", + "created_by", + "cancelled_by", + "approved_by", + ).prefetch_related( + "items", + "external_finished_products", + ) + + user = self.request.user + if getattr(user, "is_superuser", False): + return qs + + emp = getattr(user, "employee", None) + merchant = getattr(emp, "merchant", None) if emp else None + if not merchant: + return Shipment.objects.none() + return qs.filter(merchant=merchant) + + def patch(self, request, pk: int): + shipment = self.get_queryset().filter(id=pk).first() + if shipment is None: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + serializer = ShipmentStatusUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + from shipment.models import ShipmentStatus + from shipment.services import modify_status + + target_status = serializer.validated_data["status"] + approved_by = request.user if target_status == ShipmentStatus.APPROVED else None + + try: + shipment = modify_status( + shipment, + target_status=target_status, + operator=request.user, + approved_by=approved_by, + ) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK) + + def put(self, request, pk: int): + return self.patch(request, pk=pk) + + class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView): """ 送货单:查询列表 / 创建 @@ -912,6 +977,7 @@ class SalesItemDetailView(GenericAPIView): 销售品详情。 GET /api/v1/shipment/sales-items// + PATCH /api/v1/shipment/sales-items// """ permission_classes = [IsAuthenticated] @@ -920,7 +986,11 @@ class SalesItemDetailView(GenericAPIView): def get_queryset(self): from shipment.models import SalesItem - qs = SalesItem.objects.select_related("shipment", "created_by").order_by("id") + qs = ( + SalesItem.objects.select_related("shipment", "created_by", "delete_by") + .filter(delete_at__isnull=True) + .order_by("id") + ) user = self.request.user if getattr(user, "is_superuser", False): @@ -945,3 +1015,64 @@ class SalesItemDetailView(GenericAPIView): }, ) return Response(serializer.data) + + def patch(self, request, pk: int): + sales_item = self.get_queryset().filter(id=pk).first() + if sales_item is None: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + allowed_fields = {"quantity", "remark", "position"} + unexpected_fields = sorted(set(request.data.keys()) - allowed_fields) + if unexpected_fields: + return Response( + { + "detail": ( + "销售品仅允许修改以下字段: " + f"{', '.join(sorted(allowed_fields))};" + f"不支持字段: {', '.join(unexpected_fields)}" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = SalesItemUpdateSerializer(data=request.data, partial=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + from shipment.services import update_sales_item + + try: + sales_item = update_sales_item( + sales_item, + quantity=serializer.validated_data.get("quantity"), + remark=serializer.validated_data.get("remark"), + position=serializer.validated_data.get("position"), + operator=request.user, + ) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + response_serializer = self.get_serializer( + sales_item, + context={ + **_build_sales_item_serializer_context([sales_item]), + "request": request, + }, + ) + return Response(response_serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, pk: int): + if not request.user.has_perm("shipment.soft_delete_salesitem"): + return Response( + {"detail": "没有权限删除销售品"}, + status=status.HTTP_403_FORBIDDEN, + ) + + sales_item = self.get_queryset().filter(id=pk).first() + if sales_item is None: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + from shipment.services import delete_sales_item + + delete_sales_item(sales_item, deleted_by=request.user) + return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK) diff --git a/docs/printing_external_records_sync_2026-03-20.md b/docs/printing_external_records_sync_2026-03-20.md index 1dcd3e7..324a4b1 100644 --- a/docs/printing_external_records_sync_2026-03-20.md +++ b/docs/printing_external_records_sync_2026-03-20.md @@ -40,6 +40,7 @@ | `KhID` | 外部客户 ID | `PrintingOrder.external_customer_id` | 原样保存 | | `customer.KhName` | 外部客户名 | `PrintingOrder.external_customer_name` | 原样保存 | | `HpName` | 布料名 | `PrintingOrder.fabric` | 原样保存到订单面料字段 | +| `area` | 地区/区域 | `PrintingOrder.area` | 原样保存;取不到时回退为空字符串 `""` | | `CaoZY` | 外部操作员/业务员 | `PrintingOrder.created_by` / `PrintingOrder.external_employee_name` | 若能匹配到内部员工且员工绑定了系统用户,则 `created_by` 取该用户,`external_employee_name` 置空;否则 `created_by` 取固定同步用户,`external_employee_name` 保留原文 | | `SHDZ` | 布料来源 + 工艺 | `PrintingOrder.fabric_source` / `PrintingOrder.craft` | 以空白字符 split;第 1 段为 `fabric_source`,剩余重新拼接为 `craft` | | `SeHao` | 幅宽 | `PrintingOrder.width` | 原样保存 | @@ -52,6 +53,7 @@ 补充说明: - 外部 `HpName` 已作为布料名使用,直接写入 `PrintingOrder.fabric` +- 外部 `area` 直接写入 `PrintingOrder.area`;若外部未返回或为空,则统一写入空字符串 - `BianHaoKD`、`RiQi` 等当前未单独属性化的字段,保留在 `external_raw` 中 - `BianHaoKD` 当前样例值类似 `"1.27"`,不按日期强解析 diff --git a/docs/sales_item_delete_api.md b/docs/sales_item_delete_api.md new file mode 100644 index 0000000..33a19eb --- /dev/null +++ b/docs/sales_item_delete_api.md @@ -0,0 +1,164 @@ +# SalesItem 修改与删除 API 文档 + +本文档说明 `SalesItem` 销售品的修改与软删除接口。 + +## 修改接口 + +### 接口信息 + +- URL: `/api/v1/shipment/sales-items/{id}/` +- Method: `PATCH` +- 认证: 需要登录(JWT Token) + +说明: + +- 该接口仅允许修改部分非关系字段 +- 当前允许修改的字段只有: + - `quantity` + - `remark` + - `position` +- 不允许修改 `name`、`unit`、`shipment`、`customer_id`、`printing_job_id` 等字段 +- 成功修改后会自动写入 `SalesItemChangeRecord` + +### 请求体示例 + +```json +{ + "quantity": "120.50", + "remark": "补充备注", + "position": "A3-02" +} +``` + +### 成功响应示例 + +```json +{ + "id": 15, + "name": "测试销售品", + "quantity": "120.50", + "unit": 1, + "unit_display": "米", + "position": "A3-02", + "remark": "补充备注", + "printing_job_id": 123, + "printing_order_id": 45, + "external_order_id": "EXT-2026-001", + "customer_id": 8, + "customer_name": "客户A", + "shipment_id": null, + "shipment_date": null, + "created_at": "2026-04-06T10:00:00+08:00", + "created_by_id": 3, + "created_by_name": "测试员工", + "product_image_url": null +} +``` + +### 修改审计记录 + +每次成功修改时,会新增一条 `SalesItemChangeRecord`,记录: + +- `sales_item` +- `operator` +- `operated_at` +- `before_values` +- `after_values` + +### 错误响应示例 + +#### 1. 传入了不允许修改的字段 + +```json +{ + "detail": "销售品仅允许修改以下字段: position, quantity, remark;不支持字段: customer_id, name" +} +``` + +#### 2. 数量格式非法 + +```json +{ + "detail": "数量 abc 格式无效: [具体异常信息]" +} +``` + +#### 3. 对象不存在或无权访问 + +```json +{ + "detail": "Not found." +} +``` + +## 删除接口 + +### 接口信息 + +- URL: `/api/v1/shipment/sales-items/{id}/` +- Method: `DELETE` +- 认证: 需要登录(JWT Token) + +说明: + +- 该接口为软删除,不会物理删除数据库记录 +- 删除后销售品会从常规查询接口中隐藏 +- 删除操作需要独立 Django 权限:`shipment.soft_delete_salesitem` + +### 软删除字段 + +`SalesItem` 通过以下字段记录删除信息: + +- `delete_at`: 删除时间 +- `delete_by`: 删除人 + +### 权限要求 + +调用方需要具备: + +- `shipment.soft_delete_salesitem` + +无该权限时返回 `403 Forbidden`。 + +### 请求示例 + +```http +DELETE /api/v1/shipment/sales-items/15/ +Authorization: Bearer +``` + +### 成功响应 + +```json +{ + "detail": "销售品已标记为删除" +} +``` + +### 行为说明 + +- 软删除后,`SalesItem` 不再出现在以下常规接口结果中: + - 销售品详情 + - 按客户查询销售品 + - 按生产订单查询销售品 + - 未出货销售品客户反查 + - 出货单详情 / 列表中的嵌套销售品 +- 重复删除当前实现保持幂等;由于对象在常规查询中已隐藏,后续再次调用通常会得到 `404` + +### 错误响应示例 + +#### 1. 无权限 + +```json +{ + "detail": "没有权限删除销售品" +} +``` + +#### 2. 对象不存在或无权访问 + +```json +{ + "detail": "Not found." +} +``` diff --git a/docs/shipment_api.md b/docs/shipment_api.md index 74c5cfd..0e4a044 100644 --- a/docs/shipment_api.md +++ b/docs/shipment_api.md @@ -5,6 +5,8 @@ ## 目录 - [查询出货单](#查询出货单) +- [出货单状态流转接口](./shipment_status_api.md) +- [销售品删除接口](./sales_item_delete_api.md) - [创建出货单](#创建出货单) - [查询有待出货销售品的客户](#查询有待出货销售品的客户) - [按客户查询销售品](#按客户查询销售品) diff --git a/docs/shipment_status_api.md b/docs/shipment_status_api.md new file mode 100644 index 0000000..898ccf6 --- /dev/null +++ b/docs/shipment_status_api.md @@ -0,0 +1,132 @@ +# Shipment Status API 文档 + +本文档仅说明 `Shipment` 出货单状态流转接口。 + +## 接口信息 + +- URL: `/api/v1/shipment/shipments/{id}/status/` +- Method: `PATCH` / `PUT` +- 认证: 需要登录(JWT Token) + +说明: + +- 该接口只负责修改 `Shipment.status` +- 业务字段修改仍然使用 `/api/v1/shipment/shipments/{id}/` +- `PATCH` 与 `PUT` 当前行为一致,都是按请求体中的 `status` 执行状态流转 + +## 请求体 + +```json +{ + "status": 2 +} +``` + +字段说明: + +- `status`: 目标状态 + - `1 = 草稿(未发布)` + - `2 = 已发布` + - `3 = 已取消` + - `4 = 已驳回` + - `5 = 已审核` + +## 状态机规则 + +- `草稿(未发布)` 只能流转到 `已发布` +- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消` +- `已驳回` 可以流转到 `已审核` / `已取消` +- `已审核` 可以流转到 `已取消` +- `已取消` 不可再流转 +- 重复设置同一状态时保持幂等 + +附加规则: + +- 进入 `已审核` 时,接口会自动将当前 `request.user` 写入 `approved_by` +- 进入 `已取消` 时,接口会自动将当前 `request.user` 写入 `cancelled_by` +- 每次成功状态流转都会更新 `status_modified_at` + +## 请求示例 + +### 1. 草稿发布 + +```http +PATCH /api/v1/shipment/shipments/12/status/ +Content-Type: application/json + +{ + "status": 2 +} +``` + +### 2. 已发布审核 + +```http +PUT /api/v1/shipment/shipments/12/status/ +Content-Type: application/json + +{ + "status": 5 +} +``` + +## 成功响应示例 + +```json +{ + "id": 12, + "merchant_id": 1, + "merchant_name": "测试印花厂", + "customer": 8, + "customer_name": "客户A", + "shipment_date": "2026-04-06", + "address": "", + "contact_name": "", + "contact_phone": "", + "area": "", + "remark": "", + "status": 5, + "status_display": "已审核", + "external_id": null, + "status_modified_at": "2026-04-06T14:30:00+08:00", + "cancelled_by_id": null, + "cancelled_by_name": null, + "approved_by_id": 3, + "approved_by_name": "测试员工", + "items_count": 2, + "sales_items": [], + "external_finished_products": [], + "created_by_id": 3, + "created_by_name": "测试员工", + "created_at": "2026-04-06T10:00:00+08:00", + "updated_at": "2026-04-06T14:30:00+08:00" +} +``` + +## 错误响应示例 + +### 1. 非法流转 + +```json +{ + "detail": "不允许将出货单状态从 草稿(未发布) 修改为 已审核" +} +``` + +### 2. 越权或对象不存在 + +```json +{ + "detail": "Not found." +} +``` + +### 3. 请求体不合法 + +```json +{ + "status": [ + "\"99\" is not a valid choice." + ] +} +``` diff --git a/shipment/admin.py b/shipment/admin.py index cd33ddf..c626358 100644 --- a/shipment/admin.py +++ b/shipment/admin.py @@ -1,5 +1,11 @@ from django.contrib import admin -from .models import ExternalFinishedProduct, Shipment, SalesItem, ShipmentDelivery +from .models import ( + ExternalFinishedProduct, + SalesItem, + SalesItemChangeRecord, + Shipment, + ShipmentDelivery, +) class SalesItemInline(admin.TabularInline): @@ -64,10 +70,10 @@ class ShipmentAdmin(admin.ModelAdmin): @admin.register(SalesItem) class SalesItemAdmin(admin.ModelAdmin): - list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'created_at'] - list_filter = ['unit', 'created_at', ('shipment', admin.EmptyFieldListFilter)] + list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'delete_at', 'delete_by', 'created_at'] + list_filter = ['unit', 'created_at', 'delete_at', ('shipment', admin.EmptyFieldListFilter)] search_fields = ['name', 'shipment__customer__name', 'position', 'remark'] - readonly_fields = ['created_at', 'updated_at', 'created_by'] + readonly_fields = ['created_at', 'updated_at', 'created_by', 'delete_at', 'delete_by'] raw_id_fields = ['shipment'] def shipment_status(self, obj): @@ -96,6 +102,32 @@ class ExternalFinishedProductAdmin(admin.ModelAdmin): super().save_model(request, obj, form, change) +@admin.register(SalesItemChangeRecord) +class SalesItemChangeRecordAdmin(admin.ModelAdmin): + list_display = [ + "id", + "sales_item", + "operator", + "operated_at", + "created_at", + ] + list_filter = ["operated_at", "created_at"] + search_fields = [ + "sales_item__name", + "operator__username", + "operator__employee__name", + ] + readonly_fields = [ + "sales_item", + "operator", + "operated_at", + "before_values", + "after_values", + "created_at", + "updated_at", + ] + + @admin.register(ShipmentDelivery) class ShipmentDeliveryAdmin(admin.ModelAdmin): list_display = [ diff --git a/shipment/migrations/0017_salesitemchangerecord.py b/shipment/migrations/0017_salesitemchangerecord.py new file mode 100644 index 0000000..abf7ff1 --- /dev/null +++ b/shipment/migrations/0017_salesitemchangerecord.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("shipment", "0016_shipmentdelivery_contact_phone_and_vehicle_capacity"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SalesItemChangeRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")), + ("operated_at", models.DateTimeField(verbose_name="操作时间")), + ("before_values", models.JSONField(blank=True, default=dict, verbose_name="修改前的值")), + ("after_values", models.JSONField(blank=True, default=dict, verbose_name="修改目标值")), + ( + "operator", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sales_item_change_records", + to=settings.AUTH_USER_MODEL, + verbose_name="操作者", + ), + ), + ( + "sales_item", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="change_records", + to="shipment.salesitem", + verbose_name="销售品", + ), + ), + ], + options={ + "verbose_name": "销售品修改记录", + "verbose_name_plural": "销售品修改记录", + "db_table": "sales_item_change_record", + "ordering": ["-operated_at", "-id"], + }, + ), + ] diff --git a/shipment/migrations/0018_salesitem_soft_delete_fields.py b/shipment/migrations/0018_salesitem_soft_delete_fields.py new file mode 100644 index 0000000..2a0b621 --- /dev/null +++ b/shipment/migrations/0018_salesitem_soft_delete_fields.py @@ -0,0 +1,40 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("shipment", "0017_salesitemchangerecord"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="salesitem", + name="delete_at", + field=models.DateTimeField(blank=True, null=True, verbose_name="删除时间"), + ), + migrations.AddField( + model_name="salesitem", + name="delete_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="deleted_sales_items", + to=settings.AUTH_USER_MODEL, + verbose_name="删除人", + ), + ), + migrations.AlterModelOptions( + name="salesitem", + options={ + "ordering": ["id"], + "permissions": [("soft_delete_salesitem", "Can soft delete sales item")], + "verbose_name": "销售品", + "verbose_name_plural": "销售品", + }, + ), + ] diff --git a/shipment/models.py b/shipment/models.py index dc78056..d630436 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -302,12 +302,30 @@ class SalesItem(ModelBase): default='', verbose_name='备注' ) + + delete_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='删除时间', + ) + + delete_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='deleted_sales_items', + verbose_name='删除人', + ) class Meta: db_table = 'sales_item' verbose_name = '销售品' verbose_name_plural = '销售品' ordering = ['id'] + permissions = [ + ('soft_delete_salesitem', 'Can soft delete sales item'), + ] def __str__(self): return f'{self.name} x {self.quantity} {self.get_unit_display()}' @@ -342,6 +360,55 @@ class SalesItem(ModelBase): return None +class SalesItemChangeRecord(ModelBase): + """ + 销售品修改记录。 + + 记录允许修改字段的变更前后值,以及操作者与操作时间。 + """ + + sales_item = models.ForeignKey( + SalesItem, + on_delete=models.CASCADE, + related_name="change_records", + verbose_name="销售品", + ) + + operator = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sales_item_change_records", + verbose_name="操作者", + ) + + operated_at = models.DateTimeField( + verbose_name="操作时间", + ) + + before_values = models.JSONField( + default=dict, + blank=True, + verbose_name="修改前的值", + ) + + after_values = models.JSONField( + default=dict, + blank=True, + verbose_name="修改目标值", + ) + + class Meta: + db_table = "sales_item_change_record" + verbose_name = "销售品修改记录" + verbose_name_plural = "销售品修改记录" + ordering = ["-operated_at", "-id"] + + def __str__(self) -> str: + return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}" + + class ShipmentDelivery(ModelBase): """ 送货单 diff --git a/shipment/services.py b/shipment/services.py index d73f3da..412e4b5 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -4,6 +4,7 @@ Shipment 模块业务逻辑层 from __future__ import annotations +from decimal import Decimal, InvalidOperation from typing import List from django.db import transaction @@ -14,6 +15,7 @@ from django.utils import timezone from shipment.models import ( ExternalFinishedProduct, SalesItem, + SalesItemChangeRecord, Shipment, ShipmentDelivery, ShipmentDeliveryStatus, @@ -82,6 +84,7 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet: base_sales_items = SalesItem.objects.filter( merchant=merchant, shipment__isnull=True, + delete_at__isnull=True, customer_id=OuterRef("pk"), ) count_subquery = ( @@ -153,6 +156,7 @@ def get_sales_items_by_printing_order( # 2. 查询 SalesItem,过滤 printing_job_id 在这些 job_ids 中 queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids)) + queryset = queryset.filter(delete_at__isnull=True) # 3. 根据参数决定是否过滤已出货的销售品 if not include_already_has_shipment: @@ -176,6 +180,7 @@ def get_sales_items_by_customer( queryset = SalesItem.objects.filter( merchant=merchant, customer_id=customer_id, + delete_at__isnull=True, ) if external_order_id: @@ -193,6 +198,91 @@ def get_sales_items_by_customer( return queryset.select_related("shipment").order_by("id") +@transaction.atomic +def update_sales_item( + sales_item: SalesItem, + *, + quantity: str | Decimal | None = None, + remark: str | None = None, + position: str | None = None, + operator=None, +) -> SalesItem: + """ + 更新销售品的非关系字段。 + + 当前仅允许修改: + - quantity + - remark + - position + """ + changes: dict[str, tuple[object, object]] = {} + + if quantity is not None: + try: + normalized_quantity = Decimal(str(quantity)) + except (InvalidOperation, ValueError, TypeError) as exc: + raise ValueError(f"数量 {quantity} 格式无效: {exc}") + if normalized_quantity != sales_item.quantity: + changes["quantity"] = (sales_item.quantity, normalized_quantity) + sales_item.quantity = normalized_quantity + + if remark is not None: + normalized_remark = remark or "" + if normalized_remark != sales_item.remark: + changes["remark"] = (sales_item.remark, normalized_remark) + sales_item.remark = normalized_remark + + if position is not None: + normalized_position = position or "" + if normalized_position != sales_item.position: + changes["position"] = (sales_item.position, normalized_position) + sales_item.position = normalized_position + + if not changes: + return sales_item + + sales_item.save(update_fields=[*changes.keys(), "updated_at"]) + + def _serialize_value(value): + if isinstance(value, Decimal): + return str(value) + return value + + SalesItemChangeRecord.objects.create( + sales_item=sales_item, + operator=operator, + operated_at=timezone.now(), + before_values={ + field: _serialize_value(old_value) + for field, (old_value, _) in changes.items() + }, + after_values={ + field: _serialize_value(new_value) + for field, (_, new_value) in changes.items() + }, + ) + + return sales_item + + +@transaction.atomic +def delete_sales_item( + sales_item: SalesItem, + *, + deleted_by=None, +) -> SalesItem: + """ + 软删除销售品。 + """ + if sales_item.delete_at is not None: + return sales_item + + sales_item.delete_at = timezone.now() + sales_item.delete_by = deleted_by + sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"]) + return sales_item + + @transaction.atomic def create_shipment_delivery( *, @@ -579,7 +669,7 @@ def create_shipment( from printing.models import PrintingJob # 查询销售品 - sales_items = SalesItem.objects.filter(id__in=sales_item_ids) + sales_items = SalesItem.objects.filter(id__in=sales_item_ids, delete_at__isnull=True) found_ids = set(sales_items.values_list("id", flat=True)) missing_ids = set(sales_item_ids) - found_ids @@ -642,7 +732,7 @@ def create_shipment( # 关联销售品 if sales_item_ids: updated = SalesItem.objects.filter( - id__in=sales_item_ids, merchant=merchant + id__in=sales_item_ids, merchant=merchant, delete_at__isnull=True ).update(shipment=shipment) if updated != len(sales_item_ids): raise ValueError("存在不属于当前商户的销售品,无法关联到出货单") @@ -752,7 +842,6 @@ def create_sales_item( Raises: ValueError: 如果生产任务不存在或不属于当前商户 """ - from decimal import Decimal, InvalidOperation from printing.models import PrintingJob # 获取当前用户的商户