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.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)))
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)))

View File

@@ -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))

View File

@@ -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/<int:pk>/rebuild/",
SalesItemRebuildView.as_view(),
name="sales_item_rebuild",
),
path(
"shipment/sales-items/", SalesItemCreateView.as_view(), name="sales_item_create"
),

View File

@@ -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',

View File

@@ -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="新的数量(可选,未传则沿用原销售品数量)",
)

View File

@@ -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"""

View File

@@ -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/<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,
)