1
0
forked from erp-dev/erp

feat: salesitem can change/soft delete

This commit is contained in:
2026-04-06 14:49:02 +08:00
parent d7bc43082e
commit 9afbb73799
16 changed files with 1075 additions and 10 deletions

View File

@@ -8,6 +8,7 @@ from .views import (
SalesItemByCustomerView,
SalesItemByPrintingOrderView,
SalesItemCreateView,
ShipmentStatusUpdateView,
ShipmentDeliveryBindShipmentsView,
ShipmentDeliveryDetailView,
ShipmentDeliveryListCreateView,
@@ -24,6 +25,7 @@ __all__ = [
'SalesItemByCustomerView',
'SalesItemByPrintingOrderView',
'SalesItemCreateView',
'ShipmentStatusUpdateView',
'ShipmentDeliveryBindShipmentsView',
'ShipmentDeliveryDetailView',
'ShipmentDeliveryListCreateView',

View File

@@ -8,6 +8,7 @@ from shipment.models import (
ExternalFinishedProduct,
SalesItem,
Shipment,
ShipmentStatus,
ShipmentDelivery,
ShipmentDeliveryStatus,
)
@@ -129,7 +130,7 @@ class ShipmentSerializer(serializers.ModelSerializer):
return None
def get_items_count(self, obj):
return obj.items.count()
return obj.items.filter(delete_at__isnull=True).count()
def get_cancelled_by_name(self, obj):
if obj.cancelled_by:
@@ -156,7 +157,7 @@ class ShipmentSerializer(serializers.ModelSerializer):
"""
# 优先使用 prefetch 的 related manager兜底为 none()
rel = getattr(obj, "items", None)
items = list(rel.all()) if rel is not None else []
items = list(rel.filter(delete_at__isnull=True)) if rel is not None else []
serializer_context = dict(self.context)
serializer_context.update(_build_nested_sales_item_context(items))
return SalesItemDetailSerializer(
@@ -361,6 +362,13 @@ class ShipmentUpdateSerializer(serializers.Serializer):
)
class ShipmentStatusUpdateSerializer(serializers.Serializer):
status = serializers.ChoiceField(
choices=ShipmentStatus.choices,
help_text="出货单状态1=草稿(未发布), 2=已发布, 3=已取消, 4=已驳回, 5=已审核)",
)
class SalesItemSerializer(serializers.Serializer):
"""
销售品序列化器(只读)
@@ -727,3 +735,34 @@ class SalesItemCreateSerializer(serializers.Serializer):
remark=validated_data.get("remark", ""),
position=validated_data.get("position", ""),
)
class SalesItemUpdateSerializer(serializers.Serializer):
"""
销售品更新序列化器。
当前仅开放数量、备注、货位的修改。
"""
quantity = serializers.CharField(
max_length=20,
required=False,
help_text="数量(可选,支持小数)",
)
remark = serializers.CharField(
max_length=200,
required=False,
allow_blank=True,
help_text="备注(可选)",
)
position = serializers.CharField(
max_length=200,
required=False,
allow_blank=True,
help_text="货位(可选)",
)
def validate(self, attrs):
if not attrs:
raise serializers.ValidationError("至少提供一个可修改字段")
return attrs

View File

@@ -784,6 +784,115 @@ class SalesItemDetailAPITestCase(APITestCase):
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
def test_patch_sales_item_success_and_create_change_record(self):
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"quantity": "99.50",
"remark": "改备注",
"position": "C3-08",
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.sales_item.refresh_from_db()
self.assertEqual(self.sales_item.quantity, Decimal("99.50"))
self.assertEqual(self.sales_item.remark, "改备注")
self.assertEqual(self.sales_item.position, "C3-08")
change_record = self.sales_item.change_records.get()
self.assertEqual(change_record.operator_id, self.user.id)
self.assertEqual(
change_record.before_values,
{
"quantity": "88.00",
"remark": "",
"position": "B2-03",
},
)
self.assertEqual(
change_record.after_values,
{
"quantity": "99.50",
"remark": "改备注",
"position": "C3-08",
},
)
def test_patch_sales_item_rejects_relation_or_other_disallowed_fields(self):
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"name": "不允许改名",
"customer_id": self.customer.id,
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("仅允许修改以下字段", resp.json()["detail"])
self.assertFalse(self.sales_item.change_records.exists())
def test_patch_sales_item_invalid_quantity(self):
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"quantity": "abc",
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("数量 abc 格式无效", resp.json()["detail"])
self.assertFalse(self.sales_item.change_records.exists())
def test_patch_sales_item_other_merchant_404(self):
self.client.force_authenticate(user=self.other_user)
resp = self.client.patch(
f"/api/v1/shipment/sales-items/{self.sales_item.id}/",
{
"remark": "无权限修改",
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
def test_delete_sales_item_requires_permission(self):
resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
self.sales_item.refresh_from_db()
self.assertIsNone(self.sales_item.delete_at)
self.assertIsNone(self.sales_item.delete_by)
def test_delete_sales_item_success_soft_deletes_and_hides_detail(self):
permission = Permission.objects.get(codename="soft_delete_salesitem")
self.user.user_permissions.add(permission)
resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.json()["detail"], "销售品已标记为删除")
self.sales_item.refresh_from_db()
self.assertIsNotNone(self.sales_item.delete_at)
self.assertEqual(self.sales_item.delete_by, self.user)
detail_resp = self.client.get(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(detail_resp.status_code, status.HTTP_404_NOT_FOUND)
def test_delete_sales_item_other_merchant_404(self):
permission = Permission.objects.get(codename="soft_delete_salesitem")
self.other_user.user_permissions.add(permission)
self.client.force_authenticate(user=self.other_user)
resp = self.client.delete(f"/api/v1/shipment/sales-items/{self.sales_item.id}/")
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
class ShipmentCreateAPITestCase(TestCase):
"""测试创建出货单 API"""
@@ -1683,6 +1792,166 @@ class ShipmentStatusServiceTestCase(TestCase):
)
class ShipmentStatusAPITestCase(APITestCase):
"""测试出货单状态流转 API"""
def setUp(self):
self.client = APIClient()
self.merchant = basic_models.Merchant.objects.create(
name="状态 API 商户", type=basic_models.MerchantTypeEnum.FACTORY
)
self.other_merchant = basic_models.Merchant.objects.create(
name="状态 API 其它商户", type=basic_models.MerchantTypeEnum.FACTORY
)
self.user = User.objects.create_user(
username="shipment_status_api_user",
password="testpass123",
email="shipment_status_api@example.com",
)
self.employee = basic_models.Employee.objects.create(
sys_user=self.user,
merchant=self.merchant,
name="状态 API 员工",
mobile="13800138101",
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
self.other_user = User.objects.create_user(
username="shipment_status_api_other_user",
password="testpass123",
email="shipment_status_api_other@example.com",
)
self.other_employee = basic_models.Employee.objects.create(
sys_user=self.other_user,
merchant=self.other_merchant,
name="状态 API 其它员工",
mobile="13800138102",
status=basic_models.EmployeeStatusEnum.ACTIVE,
)
self.customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name="状态 API 客户",
mobile="13900139101",
area="杭州",
)
self.shipment = shipment_models.Shipment.objects.create(
merchant=self.merchant,
customer=self.customer,
shipment_date="2026-04-02",
created_by=self.user,
)
self.client.force_authenticate(user=self.user)
def test_patch_status_draft_to_published_success(self):
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.PUBLISHED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.PUBLISHED)
self.assertIsNotNone(self.shipment.status_modified_at)
self.assertIsNone(self.shipment.approved_by)
self.assertIsNone(self.shipment.cancelled_by)
def test_put_status_published_to_approved_sets_approved_by(self):
from shipment.services import modify_status
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
resp = self.client.put(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.APPROVED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.APPROVED)
self.assertEqual(self.shipment.approved_by, self.user)
def test_patch_status_published_to_rejected_success(self):
from shipment.services import modify_status
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.REJECTED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.REJECTED)
def test_patch_status_to_cancelled_sets_cancelled_by(self):
from shipment.services import modify_status
modify_status(
self.shipment,
target_status=shipment_models.ShipmentStatus.PUBLISHED,
operator=self.user,
)
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.CANCELLED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.shipment.refresh_from_db()
self.assertEqual(self.shipment.status, shipment_models.ShipmentStatus.CANCELLED)
self.assertEqual(self.shipment.cancelled_by, self.user)
def test_patch_status_rejects_invalid_transition(self):
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.APPROVED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("不允许将出货单状态从 草稿(未发布) 修改为 已审核", resp.json()["detail"])
def test_patch_status_other_merchant_404(self):
self.client.force_authenticate(user=self.other_user)
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.PUBLISHED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
def test_patch_status_unauthenticated(self):
self.client.logout()
resp = self.client.patch(
f"/api/v1/shipment/shipments/{self.shipment.id}/status/",
{"status": shipment_models.ShipmentStatus.PUBLISHED},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
class SalesItemCreateAPITestCase(APITestCase):
"""销售品创建 API 测试"""

View File

@@ -15,6 +15,8 @@ from shipment.models import Shipment, ShipmentDelivery
from .serializers import (
SalesItemDetailSerializer,
SalesItemSerializer,
SalesItemUpdateSerializer,
ShipmentStatusUpdateSerializer,
ShipmentDeliveryBindShipmentsSerializer,
ShipmentDeliveryCreateSerializer,
ShipmentDeliverySerializer,
@@ -274,6 +276,69 @@ class ShipmentDetailView(RetrieveModelMixin, GenericAPIView):
return self.patch(request, pk=pk)
class ShipmentStatusUpdateView(APIView):
"""
更新出货单状态
PATCH /api/v1/shipment/shipments/<id>/status/
PUT /api/v1/shipment/shipments/<id>/status/
"""
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = Shipment.objects.all().select_related(
"merchant",
"customer",
"created_by",
"cancelled_by",
"approved_by",
).prefetch_related(
"items",
"external_finished_products",
)
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 Shipment.objects.none()
return qs.filter(merchant=merchant)
def patch(self, request, pk: int):
shipment = self.get_queryset().filter(id=pk).first()
if shipment is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
serializer = ShipmentStatusUpdateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.models import ShipmentStatus
from shipment.services import modify_status
target_status = serializer.validated_data["status"]
approved_by = request.user if target_status == ShipmentStatus.APPROVED else None
try:
shipment = modify_status(
shipment,
target_status=target_status,
operator=request.user,
approved_by=approved_by,
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(ShipmentSerializer(shipment).data, status=status.HTTP_200_OK)
def put(self, request, pk: int):
return self.patch(request, pk=pk)
class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView):
"""
送货单:查询列表 / 创建
@@ -912,6 +977,7 @@ class SalesItemDetailView(GenericAPIView):
销售品详情。
GET /api/v1/shipment/sales-items/<id>/
PATCH /api/v1/shipment/sales-items/<id>/
"""
permission_classes = [IsAuthenticated]
@@ -920,7 +986,11 @@ class SalesItemDetailView(GenericAPIView):
def get_queryset(self):
from shipment.models import SalesItem
qs = SalesItem.objects.select_related("shipment", "created_by").order_by("id")
qs = (
SalesItem.objects.select_related("shipment", "created_by", "delete_by")
.filter(delete_at__isnull=True)
.order_by("id")
)
user = self.request.user
if getattr(user, "is_superuser", False):
@@ -945,3 +1015,64 @@ class SalesItemDetailView(GenericAPIView):
},
)
return Response(serializer.data)
def patch(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)
allowed_fields = {"quantity", "remark", "position"}
unexpected_fields = sorted(set(request.data.keys()) - allowed_fields)
if unexpected_fields:
return Response(
{
"detail": (
"销售品仅允许修改以下字段: "
f"{', '.join(sorted(allowed_fields))}"
f"不支持字段: {', '.join(unexpected_fields)}"
)
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = SalesItemUpdateSerializer(data=request.data, partial=True)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from shipment.services import update_sales_item
try:
sales_item = update_sales_item(
sales_item,
quantity=serializer.validated_data.get("quantity"),
remark=serializer.validated_data.get("remark"),
position=serializer.validated_data.get("position"),
operator=request.user,
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
response_serializer = self.get_serializer(
sales_item,
context={
**_build_sales_item_serializer_context([sales_item]),
"request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
def delete(self, request, pk: int):
if not request.user.has_perm("shipment.soft_delete_salesitem"):
return Response(
{"detail": "没有权限删除销售品"},
status=status.HTTP_403_FORBIDDEN,
)
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)
from shipment.services import delete_sales_item
delete_sales_item(sales_item, deleted_by=request.user)
return Response({"detail": "销售品已标记为删除"}, status=status.HTTP_200_OK)