forked from erp-dev/erp
feat: retry haobuye sync order
This commit is contained in:
@@ -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)))
|
||||
|
||||
118
api_v1/tasks.py
118
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))
|
||||
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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="新的数量(可选,未传则沿用原销售品数量)",
|
||||
)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user