1
0
forked from erp-dev/erp

feat: retry haobuye sync order

This commit is contained in:
2026-04-07 16:56:46 +08:00
parent ebd687ccdd
commit 76ea445880
15 changed files with 1098 additions and 118 deletions

View File

@@ -1,13 +1,6 @@
from datetime import date
from django.core.management.base import BaseCommand, CommandError 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 ( from api_v1.tasks import (
_get_printing_sync_product_category, retry_external_printing_sync_failures_impl,
_get_printing_sync_user,
_sync_external_printing_records_batch,
) )
@@ -32,100 +25,14 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
limit = options.get('limit') limit = options.get('limit')
if limit is not None:
limit = max(1, int(limit))
run_date_text = options.get('run_date') run_date_text = options.get('run_date')
record_ids = options.get('record_id') or [] record_ids = options.get('record_id') or []
try:
sync_user = _get_printing_sync_user() result = retry_external_printing_sync_failures_impl(
merchant = sync_user.employee.merchant limit=limit,
category = _get_printing_sync_product_category(merchant) run_date_text=run_date_text,
record_ids=record_ids,
failures_qs = api_models.PrintingExternalSyncFailure.objects.all().order_by( )
'external_record_id', except ValueError as exc:
'-created_at', raise CommandError(f'--run-date 格式非法: {run_date_text}') from exc
'-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))) self.stdout.write(self.style.SUCCESS(str(result)))

View File

@@ -8,7 +8,7 @@ import re
import shutil import shutil
import subprocess import subprocess
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from pathlib import Path 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): def _run_fetch(page: int, page_size: int):
return asyncio.run(fetch_products_from_mingdaoyun(page=page, page_size=page_size)) return asyncio.run(fetch_products_from_mingdaoyun(page=page, page_size=page_size))

View File

@@ -34,6 +34,7 @@ from .views.users import CreateUserWithProfileView
from .views.mingdaoyun import MDYPlateOrderStagingViewSet from .views.mingdaoyun import MDYPlateOrderStagingViewSet
from .views.shipment import ( from .views.shipment import (
SalesItemDetailView, SalesItemDetailView,
SalesItemRebuildView,
SalesItemByCustomerView, SalesItemByCustomerView,
SalesItemByPrintingOrderView, SalesItemByPrintingOrderView,
SalesItemCreateView, SalesItemCreateView,
@@ -364,6 +365,11 @@ urlpatterns = [
SalesItemDetailView.as_view(), SalesItemDetailView.as_view(),
name="sales_item_detail", name="sales_item_detail",
), ),
path(
"shipment/sales-items/<int:pk>/rebuild/",
SalesItemRebuildView.as_view(),
name="sales_item_rebuild",
),
path( path(
"shipment/sales-items/", SalesItemCreateView.as_view(), name="sales_item_create" "shipment/sales-items/", SalesItemCreateView.as_view(), name="sales_item_create"
), ),

View File

@@ -5,6 +5,7 @@ Shipment API 模块
""" """
from .views import ( from .views import (
SalesItemDetailView, SalesItemDetailView,
SalesItemRebuildView,
SalesItemByCustomerView, SalesItemByCustomerView,
SalesItemByPrintingOrderView, SalesItemByPrintingOrderView,
SalesItemCreateView, SalesItemCreateView,
@@ -22,6 +23,7 @@ from .views import (
__all__ = [ __all__ = [
'SalesItemDetailView', 'SalesItemDetailView',
'SalesItemRebuildView',
'SalesItemByCustomerView', 'SalesItemByCustomerView',
'SalesItemByPrintingOrderView', 'SalesItemByPrintingOrderView',
'SalesItemCreateView', 'SalesItemCreateView',

View File

@@ -394,6 +394,7 @@ class SalesItemSerializer(serializers.Serializer):
shipment_date = serializers.DateField( shipment_date = serializers.DateField(
source="shipment.shipment_date", read_only=True, allow_null=True 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_at = serializers.DateTimeField(read_only=True)
created_by_id = serializers.IntegerField( created_by_id = serializers.IntegerField(
source="created_by.id", read_only=True, allow_null=True source="created_by.id", read_only=True, allow_null=True
@@ -706,6 +707,60 @@ class SalesItemCreateSerializer(serializers.Serializer):
default="", default="",
help_text="货位(可选)", 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): def validate_unit(self, value):
"""验证单位值是否在允许范围内""" """验证单位值是否在允许范围内"""
@@ -734,6 +789,7 @@ class SalesItemCreateSerializer(serializers.Serializer):
customer_id=validated_data.get("customer_id"), customer_id=validated_data.get("customer_id"),
remark=validated_data.get("remark", ""), remark=validated_data.get("remark", ""),
position=validated_data.get("position", ""), position=validated_data.get("position", ""),
merge_remark=validated_data.get("merge_remark"),
) )
@@ -766,3 +822,21 @@ class SalesItemUpdateSerializer(serializers.Serializer):
if not attrs: if not attrs:
raise serializers.ValidationError("至少提供一个可修改字段") raise serializers.ValidationError("至少提供一个可修改字段")
return attrs 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="新的数量(可选,未传则沿用原销售品数量)",
)

View File

@@ -745,6 +745,18 @@ class SalesItemDetailAPITestCase(APITestCase):
mobile="13800138032", mobile="13800138032",
status=basic_models.EmployeeStatusEnum.ACTIVE, 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( self.customer = basic_models.Customer.objects.create(
merchant=self.merchant, merchant=self.merchant,
@@ -783,6 +795,47 @@ class SalesItemDetailAPITestCase(APITestCase):
unit="", unit="",
created_by=self.user, 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( self.sales_item = shipment_models.SalesItem.objects.create(
merchant=self.merchant, merchant=self.merchant,
name="详情销售品", name="详情销售品",
@@ -943,6 +996,118 @@ class SalesItemDetailAPITestCase(APITestCase):
self.sales_item.refresh_from_db() self.sales_item.refresh_from_db()
self.assertIsNone(self.sales_item.delete_at) 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): class ShipmentCreateAPITestCase(TestCase):
"""测试创建出货单 API""" """测试创建出货单 API"""

View File

@@ -14,6 +14,7 @@ from shipment.models import Shipment, ShipmentDelivery
from .serializers import ( from .serializers import (
SalesItemDetailSerializer, SalesItemDetailSerializer,
SalesItemRebuildSerializer,
SalesItemSerializer, SalesItemSerializer,
SalesItemUpdateSerializer, SalesItemUpdateSerializer,
ShipmentStatusUpdateSerializer, ShipmentStatusUpdateSerializer,
@@ -984,6 +985,7 @@ class SalesItemDetailView(GenericAPIView):
serializer_class = SalesItemDetailSerializer serializer_class = SalesItemDetailSerializer
def get_queryset(self): def get_queryset(self):
from shipment.models import SalesItem
from shipment.services import get_active_sales_items_queryset from shipment.services import get_active_sales_items_queryset
qs = ( qs = (
@@ -1079,3 +1081,81 @@ class SalesItemDetailView(GenericAPIView):
except ValueError as e: except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK) return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK)
class SalesItemRebuildView(GenericAPIView):
"""
销售品重建。
POST /api/v1/shipment/sales-items/<id>/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,
)

View File

@@ -1,6 +1,6 @@
# SalesItem 修改与删除 API 文档 # 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 不存在或不属于当前商户"
}
```
## 删除接口 ## 删除接口
### 接口信息 ### 接口信息

View File

@@ -0,0 +1,222 @@
# 销售品合卷备注merge_remarkAPI 变更说明
> 变更日期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[] | 参与本次合卷的所有子单IDprinting_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/<id>/
```
响应体新增 `merge_remark` 字段。无合卷信息时值为 `null`
---
### 3. 按客户查询销售品列表(新增返回字段)
```
GET /api/v1/shipment/sales-items/by-customer/<customer_id>/
```
列表中每条销售品对象新增 `merge_remark` 字段。
---
### 4. 按生产订单查询销售品列表(新增返回字段)
```
GET /api/v1/shipment/sales-items/by-printing-order/<printing_order_id>/
```
列表中每条销售品对象新增 `merge_remark` 字段。
---
### 5. 出货单详情中的嵌套销售品列表(新增返回字段)
```
GET /api/v1/shipment/shipments/<id>/
```
响应体 `sales_items` 数组中每条销售品对象新增 `merge_remark` 字段。
---
## 二、不受影响的 API
| 端点 | 说明 |
|------|------|
| `PATCH /api/v1/shipment/sales-items/<id>/` | 更新销售品,**不接受** `merge_remark` 参数,传入会被忽略 |
| `POST /api/v1/shipment/sales-items/<id>/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` 即可区分是否为合卷销售品。

View File

@@ -602,6 +602,13 @@ CELERY_BEAT_SCHEDULE = {
'dry_run': False, '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 分钟触发一次) # - 仅在 02:00-08:00 时间窗内持续运行(每 N 分钟触发一次)
# - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS # - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS

View File

@@ -3,6 +3,7 @@ from .models import (
ExternalFinishedProduct, ExternalFinishedProduct,
SalesItem, SalesItem,
SalesItemChangeRecord, SalesItemChangeRecord,
SalesItemRebuildRecord,
Shipment, Shipment,
ShipmentDelivery, 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) @admin.register(ShipmentDelivery)
class ShipmentDeliveryAdmin(admin.ModelAdmin): class ShipmentDeliveryAdmin(admin.ModelAdmin):
list_display = [ list_display = [

View File

@@ -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"],
},
),
]

View File

@@ -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='合卷备注'),
),
]

View File

@@ -318,6 +318,13 @@ class SalesItem(ModelBase):
verbose_name='删除人', 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: class Meta:
db_table = 'sales_item' db_table = 'sales_item'
verbose_name = '销售品' verbose_name = '销售品'
@@ -409,6 +416,76 @@ class SalesItemChangeRecord(ModelBase):
return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}" 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): class ShipmentDelivery(ModelBase):
""" """
送货单 送货单

View File

@@ -16,6 +16,7 @@ from shipment.models import (
ExternalFinishedProduct, ExternalFinishedProduct,
SalesItem, SalesItem,
SalesItemChangeRecord, SalesItemChangeRecord,
SalesItemRebuildRecord,
Shipment, Shipment,
ShipmentDelivery, ShipmentDelivery,
ShipmentDeliveryStatus, ShipmentDeliveryStatus,
@@ -50,6 +51,25 @@ def ensure_sales_item_can_be_soft_deleted(sales_item: SalesItem) -> None:
raise ValueError("已关联出货单的销售品不允许删除") 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( def _resolve_delivery_shipments(
*, *,
shipment_ids: list[int], shipment_ids: list[int],
@@ -302,6 +322,64 @@ def delete_sales_item(
return 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 @transaction.atomic
def create_shipment_delivery( def create_shipment_delivery(
*, *,
@@ -841,6 +919,7 @@ def create_sales_item(
customer_id: int | None = None, customer_id: int | None = None,
remark: str = "", remark: str = "",
position: str = "", position: str = "",
merge_remark: dict | None = None,
) -> SalesItem: ) -> SalesItem:
""" """
手动创建销售品 手动创建销售品
@@ -861,8 +940,6 @@ def create_sales_item(
Raises: Raises:
ValueError: 如果生产任务不存在或不属于当前商户 ValueError: 如果生产任务不存在或不属于当前商户
""" """
from printing.models import PrintingJob
# 获取当前用户的商户 # 获取当前用户的商户
emp = getattr(created_by, "employee", None) emp = getattr(created_by, "employee", None)
merchant = getattr(emp, "merchant", None) if emp else None merchant = getattr(emp, "merchant", None) if emp else None
@@ -870,19 +947,13 @@ def create_sales_item(
raise ValueError("用户未关联商户,无法创建销售品") raise ValueError("用户未关联商户,无法创建销售品")
# 验证生产任务存在且属于当前商户 # 验证生产任务存在且属于当前商户
try: printing_job = _get_printing_job_for_sales_item(
printing_job = PrintingJob.objects.select_related("printing_order").get( printing_job_id=printing_job_id,
id=printing_job_id, merchant=merchant,
merchant=merchant, )
)
except PrintingJob.DoesNotExist:
raise ValueError(f"生产任务 {printing_job_id} 不存在或不属于当前商户")
# 转换数量为Decimal # 转换数量为Decimal
try: quantity_decimal = _normalize_sales_item_quantity(quantity)
quantity_decimal = Decimal(str(quantity))
except (InvalidOperation, ValueError, TypeError) as e:
raise ValueError(f"数量 {quantity} 格式无效: {e}")
# 如果未提供客户ID尝试从主订单获取 # 如果未提供客户ID尝试从主订单获取
if ( if (
@@ -904,6 +975,35 @@ def create_sales_item(
customer_id=customer_id, customer_id=customer_id,
remark=remark, remark=remark,
position=position, position=position,
merge_remark=merge_remark,
) )
return sales_item 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]