diff --git a/api_v1/management/commands/retry_external_printing_sync_failures.py b/api_v1/management/commands/retry_external_printing_sync_failures.py index 0f8cdbd..b1483c4 100644 --- a/api_v1/management/commands/retry_external_printing_sync_failures.py +++ b/api_v1/management/commands/retry_external_printing_sync_failures.py @@ -1,13 +1,6 @@ -from datetime import date - from django.core.management.base import BaseCommand, CommandError -from django.utils import timezone - -from api_v1 import models as api_models from api_v1.tasks import ( - _get_printing_sync_product_category, - _get_printing_sync_user, - _sync_external_printing_records_batch, + retry_external_printing_sync_failures_impl, ) @@ -32,100 +25,14 @@ class Command(BaseCommand): def handle(self, *args, **options): limit = options.get('limit') - if limit is not None: - limit = max(1, int(limit)) run_date_text = options.get('run_date') record_ids = options.get('record_id') or [] - - sync_user = _get_printing_sync_user() - merchant = sync_user.employee.merchant - category = _get_printing_sync_product_category(merchant) - - failures_qs = api_models.PrintingExternalSyncFailure.objects.all().order_by( - 'external_record_id', - '-created_at', - '-id', - ) - if run_date_text: - try: - failures_qs = failures_qs.filter(run_date=date.fromisoformat(run_date_text)) - except ValueError as exc: - raise CommandError(f'--run-date 格式非法: {run_date_text}') from exc - if record_ids: - failures_qs = failures_qs.filter(external_record_id__in=record_ids) - - selected_failures = [] - selected_record_ids = set() - for failure in failures_qs: - if failure.external_record_id in selected_record_ids: - continue - selected_failures.append(failure) - selected_record_ids.add(failure.external_record_id) - if limit is not None and len(selected_failures) >= limit: - break - - if not selected_failures: - self.stdout.write(self.style.SUCCESS(str({'retried_records': 0, 'message': '没有可重试的失败记录'}))) - return - - retry_records: list[dict] = [] - failure_by_record_id: dict[int, api_models.PrintingExternalSyncFailure] = {} - skipped_records = 0 - - for failure in selected_failures: - raw = failure.raw or {} - if not isinstance(raw, dict) or not raw: - failure.error = '失败记录缺少原始 raw 数据,无法重试' - failure.attempts = int(failure.attempts or 0) + 1 - failure.last_attempt_at = timezone.now() - failure.save(update_fields=['error', 'attempts', 'last_attempt_at', 'updated_at']) - skipped_records += 1 - continue - - retry_records.append(raw) - failure_by_record_id[failure.external_record_id] = failure - - def _update_failure(*, record: dict, error: str): - external_record_id = int(record.get('ID') or 0) - failure = failure_by_record_id.get(external_record_id) - if not failure: - return - failure.error = error or '' - failure.raw = record - failure.attempts = int(failure.attempts or 0) + 1 - failure.last_attempt_at = timezone.now() - failure.save(update_fields=['error', 'raw', 'attempts', 'last_attempt_at', 'updated_at']) - - batch_result = _sync_external_printing_records_batch( - records=retry_records, - merchant=merchant, - sync_user=sync_user, - category=category, - record_failure=_update_failure, - ) - - failed_ids = set(batch_result['failed_record_ids']) - succeeded_ids = [record_id for record_id in failure_by_record_id.keys() if record_id not in failed_ids] - - deleted_failures = 0 - if succeeded_ids: - deleted_failures, _deleted_detail = api_models.PrintingExternalSyncFailure.objects.filter( - external_record_id__in=succeeded_ids - ).delete() - - remaining_failures = api_models.PrintingExternalSyncFailure.objects.count() - - result = { - 'limit': limit, - 'retried_records': len(retry_records), - 'skipped_records': skipped_records, - 'orders_created': batch_result['orders_created'], - 'orders_updated': batch_result['orders_updated'], - 'jobs_created': batch_result['jobs_created'], - 'jobs_updated': batch_result['jobs_updated'], - 'failed_records': batch_result['failed_records'], - 'failed_record_ids': batch_result['failed_record_ids'], - 'deleted_failures': deleted_failures, - 'remaining_failures': remaining_failures, - } - self.stdout.write(self.style.SUCCESS(str(result))) \ No newline at end of file + try: + result = retry_external_printing_sync_failures_impl( + limit=limit, + run_date_text=run_date_text, + record_ids=record_ids, + ) + except ValueError as exc: + raise CommandError(f'--run-date 格式非法: {run_date_text}') from exc + self.stdout.write(self.style.SUCCESS(str(result))) diff --git a/api_v1/tasks.py b/api_v1/tasks.py index c5a4683..f57e4ab 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -8,7 +8,7 @@ import re import shutil import subprocess from collections import defaultdict -from datetime import datetime +from datetime import date, datetime from decimal import Decimal, InvalidOperation from pathlib import Path @@ -767,6 +767,122 @@ def _sync_external_printing_records_batch( } +def retry_external_printing_sync_failures_impl( + *, + limit: int | None = None, + run_date_text: str | None = None, + record_ids: list[int] | None = None, +) -> dict: + if limit is not None: + limit = max(1, int(limit)) + record_ids = record_ids or [] + + sync_user = _get_printing_sync_user() + merchant = sync_user.employee.merchant + category = _get_printing_sync_product_category(merchant) + + failures_qs = api_models.PrintingExternalSyncFailure.objects.all().order_by( + 'external_record_id', + '-created_at', + '-id', + ) + if run_date_text: + failures_qs = failures_qs.filter(run_date=date.fromisoformat(run_date_text)) + if record_ids: + failures_qs = failures_qs.filter(external_record_id__in=record_ids) + + selected_failures = [] + selected_record_ids = set() + for failure in failures_qs: + if failure.external_record_id in selected_record_ids: + continue + selected_failures.append(failure) + selected_record_ids.add(failure.external_record_id) + if limit is not None and len(selected_failures) >= limit: + break + + if not selected_failures: + return {'limit': limit, 'retried_records': 0, 'message': '没有可重试的失败记录'} + + retry_records: list[dict] = [] + failure_by_record_id: dict[int, api_models.PrintingExternalSyncFailure] = {} + skipped_records = 0 + + for failure in selected_failures: + raw = failure.raw or {} + if not isinstance(raw, dict) or not raw: + failure.error = '失败记录缺少原始 raw 数据,无法重试' + failure.attempts = int(failure.attempts or 0) + 1 + failure.last_attempt_at = timezone.now() + failure.save(update_fields=['error', 'attempts', 'last_attempt_at', 'updated_at']) + skipped_records += 1 + continue + + retry_records.append(raw) + failure_by_record_id[failure.external_record_id] = failure + + def _update_failure(*, record: dict, error: str): + external_record_id = int(record.get('ID') or 0) + failure = failure_by_record_id.get(external_record_id) + if not failure: + return + failure.error = error or '' + failure.raw = record + failure.attempts = int(failure.attempts or 0) + 1 + failure.last_attempt_at = timezone.now() + failure.save(update_fields=['error', 'raw', 'attempts', 'last_attempt_at', 'updated_at']) + + batch_result = _sync_external_printing_records_batch( + records=retry_records, + merchant=merchant, + sync_user=sync_user, + category=category, + record_failure=_update_failure, + ) + + failed_ids = set(batch_result['failed_record_ids']) + succeeded_ids = [record_id for record_id in failure_by_record_id.keys() if record_id not in failed_ids] + + deleted_failures = 0 + if succeeded_ids: + deleted_failures, _deleted_detail = api_models.PrintingExternalSyncFailure.objects.filter( + external_record_id__in=succeeded_ids + ).delete() + + remaining_failures = api_models.PrintingExternalSyncFailure.objects.count() + + return { + 'limit': limit, + 'retried_records': len(retry_records), + 'skipped_records': skipped_records, + 'orders_created': batch_result['orders_created'], + 'orders_updated': batch_result['orders_updated'], + 'jobs_created': batch_result['jobs_created'], + 'jobs_updated': batch_result['jobs_updated'], + 'failed_records': batch_result['failed_records'], + 'failed_record_ids': batch_result['failed_record_ids'], + 'deleted_failures': deleted_failures, + 'remaining_failures': remaining_failures, + } + + +@shared_task(bind=True) +def retry_external_printing_sync_failures( + self, + limit: int = 100, + run_date_text: str | None = None, + record_ids: list[int] | None = None, +): + payload = retry_external_printing_sync_failures_impl( + limit=max(1, int(limit or 100)), + run_date_text=run_date_text, + record_ids=record_ids or [], + ) + payload['task_id'] = self.request.id + logger.info('重试外部印染同步失败记录完成: %s', payload) + return payload + + def _run_fetch(page: int, page_size: int): return asyncio.run(fetch_products_from_mingdaoyun(page=page, page_size=page_size)) diff --git a/api_v1/urls.py b/api_v1/urls.py index 8cb229d..54b20e4 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -34,6 +34,7 @@ from .views.users import CreateUserWithProfileView from .views.mingdaoyun import MDYPlateOrderStagingViewSet from .views.shipment import ( SalesItemDetailView, + SalesItemRebuildView, SalesItemByCustomerView, SalesItemByPrintingOrderView, SalesItemCreateView, @@ -364,6 +365,11 @@ urlpatterns = [ SalesItemDetailView.as_view(), name="sales_item_detail", ), + path( + "shipment/sales-items//rebuild/", + SalesItemRebuildView.as_view(), + name="sales_item_rebuild", + ), path( "shipment/sales-items/", SalesItemCreateView.as_view(), name="sales_item_create" ), diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py index 56c0b3a..883d125 100644 --- a/api_v1/views/shipment/__init__.py +++ b/api_v1/views/shipment/__init__.py @@ -5,6 +5,7 @@ Shipment API 模块 """ from .views import ( SalesItemDetailView, + SalesItemRebuildView, SalesItemByCustomerView, SalesItemByPrintingOrderView, SalesItemCreateView, @@ -22,6 +23,7 @@ from .views import ( __all__ = [ 'SalesItemDetailView', + 'SalesItemRebuildView', 'SalesItemByCustomerView', 'SalesItemByPrintingOrderView', 'SalesItemCreateView', diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index bf79271..057542f 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -394,6 +394,7 @@ class SalesItemSerializer(serializers.Serializer): shipment_date = serializers.DateField( source="shipment.shipment_date", read_only=True, allow_null=True ) + merge_remark = serializers.JSONField(read_only=True, allow_null=True) created_at = serializers.DateTimeField(read_only=True) created_by_id = serializers.IntegerField( source="created_by.id", read_only=True, allow_null=True @@ -706,6 +707,60 @@ class SalesItemCreateSerializer(serializers.Serializer): default="", help_text="货位(可选)", ) + merge_remark = serializers.JSONField( + required=False, + allow_null=True, + default=None, + help_text="合卷备注JSON(可选)", + ) + + _MERGE_REMARK_REQUIRED_KEYS = { + "merge_type": str, + "jobs": list, + "main_job": int, + "quantity": str, + "unit": str, + "job_count": int, + } + + def validate_merge_remark(self, value): + """严格校验 merge_remark 的结构""" + if value is None: + return value + if not isinstance(value, dict): + raise serializers.ValidationError("merge_remark 必须是一个 JSON 对象") + + missing = set(self._MERGE_REMARK_REQUIRED_KEYS) - set(value.keys()) + if missing: + raise serializers.ValidationError(f"缺少必填字段: {sorted(missing)}") + + extra = set(value.keys()) - set(self._MERGE_REMARK_REQUIRED_KEYS) + if extra: + raise serializers.ValidationError(f"包含未知字段: {sorted(extra)}") + + for key, expected_type in self._MERGE_REMARK_REQUIRED_KEYS.items(): + if not isinstance(value[key], expected_type): + raise serializers.ValidationError( + f"字段 '{key}' 类型错误,期望 {expected_type.__name__}," + f"实际 {type(value[key]).__name__}" + ) + + # jobs 列表内的元素必须都是整数 + if not all(isinstance(j, int) for j in value["jobs"]): + raise serializers.ValidationError("jobs 列表中的元素必须为整数") + + if len(value["jobs"]) < 2: + raise serializers.ValidationError("jobs 列表至少需要包含 2 个子单ID") + + if value["main_job"] not in value["jobs"]: + raise serializers.ValidationError("main_job 必须是 jobs 列表中的一个成员") + + if value["job_count"] != len(value["jobs"]): + raise serializers.ValidationError( + f"job_count ({value['job_count']}) 与 jobs 长度 ({len(value['jobs'])}) 不一致" + ) + + return value def validate_unit(self, value): """验证单位值是否在允许范围内""" @@ -734,6 +789,7 @@ class SalesItemCreateSerializer(serializers.Serializer): customer_id=validated_data.get("customer_id"), remark=validated_data.get("remark", ""), position=validated_data.get("position", ""), + merge_remark=validated_data.get("merge_remark"), ) @@ -766,3 +822,21 @@ class SalesItemUpdateSerializer(serializers.Serializer): if not attrs: raise serializers.ValidationError("至少提供一个可修改字段") return attrs + + +class SalesItemRebuildSerializer(serializers.Serializer): + """ + 销售品重建序列化器。 + """ + + new_printing_job_id = serializers.IntegerField( + required=True, + min_value=1, + help_text="新的生产任务ID(必填)", + ) + quantity = serializers.CharField( + max_length=20, + required=False, + allow_blank=False, + help_text="新的数量(可选,未传则沿用原销售品数量)", + ) diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 513fd59..fff7202 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -745,6 +745,18 @@ class SalesItemDetailAPITestCase(APITestCase): mobile="13800138032", status=basic_models.EmployeeStatusEnum.ACTIVE, ) + self.same_merchant_other_user = User.objects.create_user( + username="sales_item_detail_same_merchant_user", + password="testpass123", + email="sales_item_detail_same_merchant@example.com", + ) + self.same_merchant_other_employee = basic_models.Employee.objects.create( + sys_user=self.same_merchant_other_user, + merchant=self.merchant, + name="同商户其它员工", + mobile="13800138033", + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) self.customer = basic_models.Customer.objects.create( merchant=self.merchant, @@ -783,6 +795,47 @@ class SalesItemDetailAPITestCase(APITestCase): unit="米", created_by=self.user, ) + self.new_printing_job = printing_models.PrintingJob.objects.create( + merchant=self.merchant, + printing_order=self.printing_order, + product=self.product, + quantity=30, + unit="米", + created_by=self.user, + ) + self.other_customer = basic_models.Customer.objects.create( + merchant=self.other_merchant, + name="其它详情测试客户", + mobile="13900139034", + area="苏州", + ) + self.other_category = basic_models.ProductCategory.objects.create( + merchant=self.other_merchant, + name="其它详情分类", + ) + self.other_product = basic_models.Product.objects.create( + merchant=self.other_merchant, + category=self.other_category, + name="其它详情产品", + human_id="DETAIL002", + ) + self.other_printing_order = printing_models.PrintingOrder.objects.create( + merchant=self.other_merchant, + customer=self.other_customer, + fabric="其它详情面料", + width="160cm", + process=self.process, + created_by=self.other_user, + external_order_id="EXT-DETAIL-002", + ) + self.foreign_printing_job = printing_models.PrintingJob.objects.create( + merchant=self.other_merchant, + printing_order=self.other_printing_order, + product=self.other_product, + quantity=40, + unit="米", + created_by=self.other_user, + ) self.sales_item = shipment_models.SalesItem.objects.create( merchant=self.merchant, name="详情销售品", @@ -943,6 +996,118 @@ class SalesItemDetailAPITestCase(APITestCase): self.sales_item.refresh_from_db() self.assertIsNone(self.sales_item.delete_at) + def test_rebuild_sales_item_success_with_original_quantity(self): + resp = self.client.post( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/", + { + "new_printing_job_id": self.new_printing_job.id, + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + data = resp.json() + self.assertEqual(data["detail"], "销售品已重建") + self.assertEqual(data["old_sales_item_id"], self.sales_item.id) + self.assertNotEqual(data["new_sales_item_id"], self.sales_item.id) + + self.sales_item.refresh_from_db() + self.assertIsNotNone(self.sales_item.delete_at) + self.assertEqual(self.sales_item.delete_by, self.user) + + new_sales_item = shipment_models.SalesItem.objects.get(id=data["new_sales_item_id"]) + self.assertEqual(new_sales_item.created_by, self.user) + self.assertEqual(new_sales_item.printing_job_id, self.new_printing_job.id) + self.assertEqual(new_sales_item.quantity, Decimal("88.00")) + self.assertEqual(new_sales_item.name, self.sales_item.name) + self.assertEqual(new_sales_item.unit, self.sales_item.unit) + self.assertEqual(new_sales_item.customer_id, self.sales_item.customer_id) + + rebuild_record = shipment_models.SalesItemRebuildRecord.objects.get( + id=data["rebuild_record_id"] + ) + self.assertEqual(rebuild_record.old_sales_item_id, self.sales_item.id) + self.assertEqual(rebuild_record.new_sales_item_id, new_sales_item.id) + self.assertEqual(rebuild_record.operator_id, self.user.id) + self.assertEqual(rebuild_record.old_printing_job_id, self.printing_job.id) + self.assertEqual(rebuild_record.new_printing_job_id, self.new_printing_job.id) + self.assertEqual(rebuild_record.old_quantity, Decimal("88.00")) + self.assertEqual(rebuild_record.new_quantity, Decimal("88.00")) + + def test_rebuild_sales_item_success_with_override_quantity(self): + resp = self.client.post( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/", + { + "new_printing_job_id": self.new_printing_job.id, + "quantity": "66.50", + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + new_sales_item = shipment_models.SalesItem.objects.get( + id=resp.json()["new_sales_item_id"] + ) + self.assertEqual(new_sales_item.quantity, Decimal("66.50")) + + def test_rebuild_sales_item_not_found(self): + resp = self.client.post( + "/api/v1/shipment/sales-items/999999/rebuild/", + { + "new_printing_job_id": self.new_printing_job.id, + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_rebuild_sales_item_creator_mismatch_returns_403(self): + self.client.force_authenticate(user=self.same_merchant_other_user) + + resp = self.client.post( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/", + { + "new_printing_job_id": self.new_printing_job.id, + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(resp.json()["detail"], "只有销售品创建者才能执行重建") + + def test_rebuild_sales_item_rejects_invalid_new_printing_job(self): + resp = self.client.post( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/", + { + "new_printing_job_id": self.foreign_printing_job.id, + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("不存在或不属于当前商户", resp.json()["detail"]) + + def test_rebuild_sales_item_rejects_when_already_bound_to_shipment(self): + shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date="2026-04-03", + created_by=self.user, + ) + self.sales_item.shipment = shipment + self.sales_item.save(update_fields=["shipment", "updated_at"]) + + resp = self.client.post( + f"/api/v1/shipment/sales-items/{self.sales_item.id}/rebuild/", + { + "new_printing_job_id": self.new_printing_job.id, + }, + format="json", + ) + + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(resp.json()["detail"], "已关联出货单的销售品不允许删除") + class ShipmentCreateAPITestCase(TestCase): """测试创建出货单 API""" diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index 41e4dce..375bd87 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -14,6 +14,7 @@ from shipment.models import Shipment, ShipmentDelivery from .serializers import ( SalesItemDetailSerializer, + SalesItemRebuildSerializer, SalesItemSerializer, SalesItemUpdateSerializer, ShipmentStatusUpdateSerializer, @@ -984,6 +985,7 @@ class SalesItemDetailView(GenericAPIView): serializer_class = SalesItemDetailSerializer def get_queryset(self): + from shipment.models import SalesItem from shipment.services import get_active_sales_items_queryset qs = ( @@ -1079,3 +1081,81 @@ class SalesItemDetailView(GenericAPIView): except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK) + + +class SalesItemRebuildView(GenericAPIView): + """ + 销售品重建。 + + POST /api/v1/shipment/sales-items//rebuild/ + """ + + permission_classes = [IsAuthenticated] + + def get_queryset(self): + from shipment.services import get_active_sales_items_queryset + + qs = ( + get_active_sales_items_queryset() + .select_related("shipment", "created_by") + .order_by("id") + ) + + 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 SalesItem.objects.none() + return qs.filter(merchant=merchant) + + def post(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) + + if sales_item.created_by_id != request.user.id: + return Response( + {"detail": "只有销售品创建者才能执行重建"}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = SalesItemRebuildSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + from shipment.models import SalesItemRebuildRecord + from shipment.services import rebuild_sales_item + + try: + new_sales_item = rebuild_sales_item( + sales_item, + new_printing_job_id=serializer.validated_data["new_printing_job_id"], + operator=request.user, + quantity=serializer.validated_data.get("quantity"), + ) + except ValueError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + rebuild_record = SalesItemRebuildRecord.objects.filter( + new_sales_item_id=new_sales_item.id + ).order_by("-id").first() + response_serializer = SalesItemDetailSerializer( + new_sales_item, + context={ + **_build_sales_item_serializer_context([new_sales_item]), + "request": request, + }, + ) + return Response( + { + "detail": "销售品已重建", + "old_sales_item_id": sales_item.id, + "new_sales_item_id": new_sales_item.id, + "rebuild_record_id": rebuild_record.id if rebuild_record else None, + "data": response_serializer.data, + }, + status=status.HTTP_201_CREATED, + ) diff --git a/docs/sales_item_delete_api.md b/docs/sales_item_delete_api.md index 33a19eb..3c85909 100644 --- a/docs/sales_item_delete_api.md +++ b/docs/sales_item_delete_api.md @@ -1,6 +1,6 @@ # SalesItem 修改与删除 API 文档 -本文档说明 `SalesItem` 销售品的修改与软删除接口。 +本文档说明 `SalesItem` 销售品的修改、重建与软删除接口。 ## 修改接口 @@ -91,6 +91,105 @@ } ``` +## 重建接口 + +### 接口信息 + +- URL: `/api/v1/shipment/sales-items/{id}/rebuild/` +- Method: `POST` +- 认证: 需要登录(JWT Token) + +说明: + +- 该接口本质上等同于“软删除旧销售品 + 基于新的 `printing_job` 重建一个新销售品” +- 只有旧销售品的 `created_by` 本人才能执行这个操作 +- 新销售品会保留旧销售品的 `created_by` +- 旧销售品若已关联出货单,则不允许重建 + +### 请求体 + +```json +{ + "new_printing_job_id": 456, + "quantity": "66.50" +} +``` + +字段说明: + +- `new_printing_job_id`: 必填,新的生产任务 ID +- `quantity`: 可选,新的数量;未传时沿用旧销售品数量 + +### 成功响应示例 + +```json +{ + "detail": "销售品已重建", + "old_sales_item_id": 15, + "new_sales_item_id": 18, + "rebuild_record_id": 3, + "data": { + "id": 18, + "name": "测试销售品", + "quantity": "66.50", + "unit": 1, + "unit_display": "米", + "position": "A3-02", + "remark": "补充备注", + "printing_job_id": 456, + "printing_order_id": 80, + "external_order_id": "EXT-2026-010", + "customer_id": 8, + "customer_name": "客户A", + "shipment_id": null, + "shipment_date": null, + "created_at": "2026-04-06T16:00:00+08:00", + "created_by_id": 3, + "created_by_name": "测试员工", + "product_image_url": null + } +} +``` + +### 重建审计记录 + +每次成功重建时,会新增一条 `SalesItemRebuildRecord`,记录: + +- `old_sales_item` +- `new_sales_item` +- `operator` +- `rebuilt_at` +- `old_printing_job_id` +- `new_printing_job_id` +- `old_quantity` +- `new_quantity` + +### 错误响应示例 + +#### 1. 不是原创建者 + +```json +{ + "detail": "只有销售品创建者才能执行重建" +} +``` + +#### 2. 旧销售品已关联出货单 + +```json +{ + "detail": "已关联出货单的销售品不允许删除" +} +``` + +#### 3. 新生产任务无效 + +```json +{ + "detail": "生产任务 999 不存在或不属于当前商户" +} +``` + ## 删除接口 ### 接口信息 diff --git a/docs/sales_item_merge_remark_api.md b/docs/sales_item_merge_remark_api.md new file mode 100644 index 0000000..a03efc6 --- /dev/null +++ b/docs/sales_item_merge_remark_api.md @@ -0,0 +1,222 @@ +# 销售品合卷备注(merge_remark)API 变更说明 + +> 变更日期:2026-04-06 + +--- + +## 概述 + +`SalesItem`(销售品)模型新增可空 JSON 字段 `merge_remark`,用于记录该销售品由多个生产子单合卷产生时的合卷信息。 + +本次变更涉及: +- **创建销售品**(POST):新增可选入参 `merge_remark`,传入时进行结构校验 +- **所有返回销售品对象的接口**:响应体中新增 `merge_remark` 字段 +- **更新销售品**(PATCH):**不支持**传入 `merge_remark`,不受影响 + +--- + +## 一、受影响的 API + +### 1. 创建销售品(新增入参) + +``` +POST /api/v1/shipment/sales-items/ +``` + +请求体新增可选字段 `merge_remark`,不传或传 `null` 均视为无合卷信息。 + +#### 完整请求参数 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| printing_job_id | integer | 是 | 生产任务ID | +| name | string | 是 | 销售品名称 | +| quantity | string | 是 | 数量(支持小数) | +| unit | integer | 是 | 单位:1=米, 2=件, 3=码, 4=个 | +| customer_id | integer | 否 | 客户ID | +| remark | string | 否 | 备注 | +| position | string | 否 | 货位 | +| **merge_remark** | object \| null | **否** | **合卷备注(见下方结构说明)** | + +#### merge_remark 结构 + +传入时必须严格符合以下结构,字段不可多、不可少: + +| 字段 | 类型 | 说明 | +|------|------|------| +| merge_type | string | 合卷类型标识,固定传 `"combined_jobs"` | +| jobs | integer[] | 参与本次合卷的所有子单ID(printing_job_id)列表,至少 2 个元素 | +| main_job | integer | 主子单ID,**必须是 jobs 列表中的成员** | +| quantity | string | 本次合卷销售品的最终数量,字符串形式 | +| unit | string | 数量单位,如 `"meter"` | +| job_count | integer | 参与合卷的子单数量,**必须等于 jobs 列表的长度** | + +#### 请求示例(含 merge_remark) + +```json +{ + "printing_job_id": 9777, + "name": "某款合卷布料", + "quantity": "647", + "unit": 1, + "merge_remark": { + "merge_type": "combined_jobs", + "jobs": [9777, 9776], + "main_job": 9777, + "quantity": "647", + "unit": "meter", + "job_count": 2 + } +} +``` + +#### 响应示例(201 Created) + +```json +{ + "id": 101, + "name": "某款合卷布料", + "quantity": "647.00", + "unit": 1, + "unit_display": "米", + "position": "", + "remark": "", + "printing_job_id": 9777, + "printing_order_id": 55, + "external_order_id": null, + "customer_id": 12, + "customer_name": "某客户", + "shipment_id": null, + "shipment_date": null, + "merge_remark": { + "merge_type": "combined_jobs", + "jobs": [9777, 9776], + "main_job": 9777, + "quantity": "647", + "unit": "meter", + "job_count": 2 + }, + "created_at": "2026-04-06T10:00:00Z", + "created_by_id": 1, + "created_by_name": "张三" +} +``` + +#### merge_remark 校验错误响应(400 Bad Request) + +**缺少必填字段:** +```json +{ + "merge_remark": ["缺少必填字段: ['job_count', 'main_job']"] +} +``` + +**包含未知字段:** +```json +{ + "merge_remark": ["包含未知字段: ['extra_field']"] +} +``` + +**字段类型错误(如 main_job 传了字符串):** +```json +{ + "merge_remark": ["字段 'main_job' 类型错误,期望 int,实际 str"] +} +``` + +**jobs 列表元素不是整数:** +```json +{ + "merge_remark": ["jobs 列表中的元素必须为整数"] +} +``` + +**jobs 少于 2 个元素:** +```json +{ + "merge_remark": ["jobs 列表至少需要包含 2 个子单ID"] +} +``` + +**main_job 不在 jobs 中:** +```json +{ + "merge_remark": ["main_job 必须是 jobs 列表中的一个成员"] +} +``` + +**job_count 与 jobs 长度不一致:** +```json +{ + "merge_remark": ["job_count (3) 与 jobs 长度 (2) 不一致"] +} +``` + +--- + +### 2. 获取销售品详情(新增返回字段) + +``` +GET /api/v1/shipment/sales-items// +``` + +响应体新增 `merge_remark` 字段。无合卷信息时值为 `null`。 + +--- + +### 3. 按客户查询销售品列表(新增返回字段) + +``` +GET /api/v1/shipment/sales-items/by-customer// +``` + +列表中每条销售品对象新增 `merge_remark` 字段。 + +--- + +### 4. 按生产订单查询销售品列表(新增返回字段) + +``` +GET /api/v1/shipment/sales-items/by-printing-order// +``` + +列表中每条销售品对象新增 `merge_remark` 字段。 + +--- + +### 5. 出货单详情中的嵌套销售品列表(新增返回字段) + +``` +GET /api/v1/shipment/shipments// +``` + +响应体 `sales_items` 数组中每条销售品对象新增 `merge_remark` 字段。 + +--- + +## 二、不受影响的 API + +| 端点 | 说明 | +|------|------| +| `PATCH /api/v1/shipment/sales-items//` | 更新销售品,**不接受** `merge_remark` 参数,传入会被忽略 | +| `POST /api/v1/shipment/sales-items//rebuild/` | 重建销售品,不涉及 `merge_remark` | +| `POST /api/v1/shipment/shipments/` | 创建出货单,不涉及 `merge_remark` | + +--- + +## 三、merge_remark 为 null 的情况 + +普通销售品(非合卷)创建时不传 `merge_remark`,返回值中该字段为 `null`: + +```json +{ + "id": 102, + "name": "普通布料", + "quantity": "200.00", + ... + "merge_remark": null +} +``` + +前端展示时直接判断 `merge_remark !== null` 即可区分是否为合卷销售品。 diff --git a/flower/settings.py b/flower/settings.py index 5968228..6efeb24 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -602,6 +602,13 @@ CELERY_BEAT_SCHEDULE = { 'dry_run': False, }, }, + 'retry_external_printing_sync_failures_every_120_minutes': { + 'task': 'api_v1.tasks.retry_external_printing_sync_failures', + 'schedule': crontab(minute='0', hour='*/2'), + 'kwargs': { + 'limit': 100, + }, + }, # 明道云开版:同步到“暂存表” # - 仅在 02:00-08:00 时间窗内持续运行(每 N 分钟触发一次) # - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS diff --git a/shipment/admin.py b/shipment/admin.py index c626358..ce0e9fc 100644 --- a/shipment/admin.py +++ b/shipment/admin.py @@ -3,6 +3,7 @@ from .models import ( ExternalFinishedProduct, SalesItem, SalesItemChangeRecord, + SalesItemRebuildRecord, Shipment, ShipmentDelivery, ) @@ -128,6 +129,38 @@ class SalesItemChangeRecordAdmin(admin.ModelAdmin): ] +@admin.register(SalesItemRebuildRecord) +class SalesItemRebuildRecordAdmin(admin.ModelAdmin): + list_display = [ + "id", + "old_sales_item", + "new_sales_item", + "operator", + "old_printing_job_id", + "new_printing_job_id", + "rebuilt_at", + ] + list_filter = ["rebuilt_at", "created_at"] + search_fields = [ + "old_sales_item__name", + "new_sales_item__name", + "operator__username", + "operator__employee__name", + ] + readonly_fields = [ + "old_sales_item", + "new_sales_item", + "operator", + "rebuilt_at", + "old_printing_job_id", + "new_printing_job_id", + "old_quantity", + "new_quantity", + "created_at", + "updated_at", + ] + + @admin.register(ShipmentDelivery) class ShipmentDeliveryAdmin(admin.ModelAdmin): list_display = [ diff --git a/shipment/migrations/0019_salesitemrebuildrecord.py b/shipment/migrations/0019_salesitemrebuildrecord.py new file mode 100644 index 0000000..78b58c9 --- /dev/null +++ b/shipment/migrations/0019_salesitemrebuildrecord.py @@ -0,0 +1,74 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("shipment", "0018_salesitem_soft_delete_fields"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SalesItemRebuildRecord", + 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="更新时间")), + ("rebuilt_at", models.DateTimeField(verbose_name="重建时间")), + ("old_printing_job_id", models.PositiveIntegerField(blank=True, null=True, verbose_name="旧生产任务ID")), + ("new_printing_job_id", models.PositiveIntegerField(verbose_name="新生产任务ID")), + ("old_quantity", models.DecimalField(decimal_places=2, max_digits=12, verbose_name="旧数量")), + ("new_quantity", models.DecimalField(decimal_places=2, max_digits=12, verbose_name="新数量")), + ( + "new_sales_item", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="rebuild_target_records", + to="shipment.salesitem", + verbose_name="新销售品", + ), + ), + ( + "old_sales_item", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="rebuild_source_records", + to="shipment.salesitem", + verbose_name="旧销售品", + ), + ), + ( + "operator", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sales_item_rebuild_records", + to=settings.AUTH_USER_MODEL, + verbose_name="操作者", + ), + ), + ], + options={ + "verbose_name": "销售品重建记录", + "verbose_name_plural": "销售品重建记录", + "db_table": "sales_item_rebuild_record", + "ordering": ["-rebuilt_at", "-id"], + }, + ), + ] diff --git a/shipment/migrations/0020_salesitem_merge_remark.py b/shipment/migrations/0020_salesitem_merge_remark.py new file mode 100644 index 0000000..179cea5 --- /dev/null +++ b/shipment/migrations/0020_salesitem_merge_remark.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-04-06 10:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipment', '0019_salesitemrebuildrecord'), + ] + + operations = [ + migrations.AddField( + model_name='salesitem', + name='merge_remark', + field=models.JSONField(blank=True, help_text='合卷信息JSON,包含 merge_type, jobs, main_job, quantity, unit, job_count', null=True, verbose_name='合卷备注'), + ), + ] diff --git a/shipment/models.py b/shipment/models.py index d630436..2dd727e 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -317,6 +317,13 @@ class SalesItem(ModelBase): related_name='deleted_sales_items', verbose_name='删除人', ) + + merge_remark = models.JSONField( + null=True, + blank=True, + verbose_name='合卷备注', + help_text='合卷信息JSON,包含 merge_type, jobs, main_job, quantity, unit, job_count', + ) class Meta: db_table = 'sales_item' @@ -409,6 +416,76 @@ class SalesItemChangeRecord(ModelBase): return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}" +class SalesItemRebuildRecord(ModelBase): + """ + 销售品重建记录。 + + 用于审计“软删除旧销售品并基于新 printing_job 重建销售品”的动作。 + """ + + old_sales_item = models.ForeignKey( + SalesItem, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="rebuild_source_records", + verbose_name="旧销售品", + ) + + new_sales_item = models.ForeignKey( + SalesItem, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="rebuild_target_records", + verbose_name="新销售品", + ) + + operator = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sales_item_rebuild_records", + verbose_name="操作者", + ) + + rebuilt_at = models.DateTimeField( + verbose_name="重建时间", + ) + + old_printing_job_id = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name="旧生产任务ID", + ) + + new_printing_job_id = models.PositiveIntegerField( + verbose_name="新生产任务ID", + ) + + old_quantity = models.DecimalField( + max_digits=12, + decimal_places=2, + verbose_name="旧数量", + ) + + new_quantity = models.DecimalField( + max_digits=12, + decimal_places=2, + verbose_name="新数量", + ) + + class Meta: + db_table = "sales_item_rebuild_record" + verbose_name = "销售品重建记录" + verbose_name_plural = "销售品重建记录" + ordering = ["-rebuilt_at", "-id"] + + def __str__(self) -> str: + return f"SalesItemRebuildRecord #{self.id} - old={self.old_sales_item_id}, new={self.new_sales_item_id}" + + class ShipmentDelivery(ModelBase): """ 送货单 diff --git a/shipment/services.py b/shipment/services.py index 9dd3bb8..a5fceef 100644 --- a/shipment/services.py +++ b/shipment/services.py @@ -16,6 +16,7 @@ from shipment.models import ( ExternalFinishedProduct, SalesItem, SalesItemChangeRecord, + SalesItemRebuildRecord, Shipment, ShipmentDelivery, ShipmentDeliveryStatus, @@ -50,6 +51,25 @@ def ensure_sales_item_can_be_soft_deleted(sales_item: SalesItem) -> None: raise ValueError("已关联出货单的销售品不允许删除") +def _get_printing_job_for_sales_item(*, printing_job_id: int, merchant): + from printing.models import PrintingJob + + try: + return PrintingJob.objects.select_related("printing_order").get( + id=printing_job_id, + merchant=merchant, + ) + except PrintingJob.DoesNotExist: + raise ValueError(f"生产任务 {printing_job_id} 不存在或不属于当前商户") + + +def _normalize_sales_item_quantity(quantity: str | Decimal) -> Decimal: + try: + return Decimal(str(quantity)) + except (InvalidOperation, ValueError, TypeError) as e: + raise ValueError(f"数量 {quantity} 格式无效: {e}") + + def _resolve_delivery_shipments( *, shipment_ids: list[int], @@ -302,6 +322,64 @@ def delete_sales_item( return sales_item +@transaction.atomic +def rebuild_sales_item( + sales_item: SalesItem, + *, + new_printing_job_id: int, + operator, + quantity: str | Decimal | None = None, +) -> SalesItem: + """ + 软删除旧销售品,并基于新 printing_job 重建一个新销售品。 + """ + ensure_sales_item_can_be_soft_deleted(sales_item) + + merchant = sales_item.merchant + printing_job = _get_printing_job_for_sales_item( + printing_job_id=new_printing_job_id, + merchant=merchant, + ) + + new_quantity = ( + _normalize_sales_item_quantity(quantity) + if quantity is not None + else sales_item.quantity + ) + + deleted_sales_item = delete_sales_item(sales_item, deleted_by=operator) + + customer_id = sales_item.customer_id + if customer_id is None and printing_job.printing_order and printing_job.printing_order.customer: + customer_id = printing_job.printing_order.customer_id + + new_sales_item = SalesItem.objects.create( + shipment=None, + merchant=merchant, + name=sales_item.name, + quantity=new_quantity, + unit=sales_item.unit, + created_by=sales_item.created_by, + printing_job_id=printing_job.id, + customer_id=customer_id, + remark=sales_item.remark, + position=sales_item.position, + ) + + SalesItemRebuildRecord.objects.create( + old_sales_item=deleted_sales_item, + new_sales_item=new_sales_item, + operator=operator, + rebuilt_at=timezone.now(), + old_printing_job_id=deleted_sales_item.printing_job_id, + new_printing_job_id=printing_job.id, + old_quantity=deleted_sales_item.quantity, + new_quantity=new_sales_item.quantity, + ) + + return new_sales_item + + @transaction.atomic def create_shipment_delivery( *, @@ -841,6 +919,7 @@ def create_sales_item( customer_id: int | None = None, remark: str = "", position: str = "", + merge_remark: dict | None = None, ) -> SalesItem: """ 手动创建销售品 @@ -861,8 +940,6 @@ def create_sales_item( Raises: ValueError: 如果生产任务不存在或不属于当前商户 """ - from printing.models import PrintingJob - # 获取当前用户的商户 emp = getattr(created_by, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None @@ -870,19 +947,13 @@ def create_sales_item( raise ValueError("用户未关联商户,无法创建销售品") # 验证生产任务存在且属于当前商户 - try: - printing_job = PrintingJob.objects.select_related("printing_order").get( - id=printing_job_id, - merchant=merchant, - ) - except PrintingJob.DoesNotExist: - raise ValueError(f"生产任务 {printing_job_id} 不存在或不属于当前商户") + printing_job = _get_printing_job_for_sales_item( + printing_job_id=printing_job_id, + merchant=merchant, + ) # 转换数量为Decimal - try: - quantity_decimal = Decimal(str(quantity)) - except (InvalidOperation, ValueError, TypeError) as e: - raise ValueError(f"数量 {quantity} 格式无效: {e}") + quantity_decimal = _normalize_sales_item_quantity(quantity) # 如果未提供客户ID,尝试从主订单获取 if ( @@ -904,6 +975,35 @@ def create_sales_item( customer_id=customer_id, remark=remark, position=position, + merge_remark=merge_remark, ) return sales_item + + +def get_merge_remark_jobs_by_main_job( + printing_job_id: int, + merchant, +) -> list[list[int]]: + """ + 查找指定 printing_job_id 作为 main_job 的所有销售品,返回每条记录的 jobs 列表。 + + 同一个 printing_job_id 可能出现在多条销售品的 main_job 中, + 因此返回值是一个二维列表,每个元素对应一条匹配记录的 jobs。 + + Args: + printing_job_id: 要查询的生产任务ID + merchant: 当前商户 + + Returns: + 匹配记录的 jobs 列表集合,无匹配时返回空列表。 + 示例: [[9777, 9776], [9777, 9780]] + """ + merge_remarks = list( + get_active_sales_items_queryset() + .filter(merchant=merchant, merge_remark__main_job=printing_job_id) + .values_list("merge_remark", flat=True) + ) + if not merge_remarks: + return [] + return [mr.get("jobs", []) for mr in merge_remarks if mr]