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

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