forked from erp-dev/erp
feat: retry haobuye sync order
This commit is contained in:
@@ -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