forked from erp-dev/erp
feat: salesitem can change/soft delete
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 测试"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user