forked from erp-dev/erp
feat: salesitem can change/soft delete
This commit is contained in:
@@ -554,6 +554,7 @@ def _build_external_order_data(
|
||||
'customer': customer,
|
||||
'fabric': str(first.get('HpName') or '').strip(),
|
||||
'width': str(first.get('SeHao') or '').strip(),
|
||||
'area': str(first.get('area') or '').strip(),
|
||||
'fabric_source': fabric_source or None,
|
||||
'craft': craft or None,
|
||||
'rolling_warn': str(first.get('BeiZhu') or '').strip() or None,
|
||||
|
||||
@@ -86,6 +86,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
||||
'ShuLiang': '10.00',
|
||||
'ShuLiangZ': '2.68',
|
||||
'YanSe': product_name,
|
||||
'area': '周边',
|
||||
'customer': {
|
||||
'KhID': 'KH00999',
|
||||
'KhName': '鸿烨服饰',
|
||||
@@ -159,6 +160,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
||||
self.assertEqual(job.product, product)
|
||||
self.assertEqual(job.printing_order.position, r'\\fw\\2026年-LWQ15\\2026\\H鸿烨\\Tj1712#')
|
||||
self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定')
|
||||
self.assertEqual(job.printing_order.area, '周边')
|
||||
|
||||
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||
@patch('api_v1.tasks._fetch_external_product_image')
|
||||
@@ -186,6 +188,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
||||
order = printing_models.PrintingOrder.objects.get(external_order_id='KD20432358')
|
||||
self.assertEqual(order.position, r'\\fw\\path\\first')
|
||||
self.assertEqual(order.fabric, '120克本白四面弹单定')
|
||||
self.assertEqual(order.area, '周边')
|
||||
self.assertEqual(printing_models.PrintingJob.objects.count(), 1)
|
||||
|
||||
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||
@@ -355,3 +358,30 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
||||
self.assertEqual(job.size, '3.15')
|
||||
self.assertEqual(job.pieces, 20)
|
||||
self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定')
|
||||
|
||||
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||
@patch('api_v1.tasks._fetch_external_product_image')
|
||||
@patch('api_v1.tasks._fetch_external_printing_records')
|
||||
def test_order_area_updates_and_defaults_to_empty_string(
|
||||
self,
|
||||
mock_fetch_records,
|
||||
mock_fetch_image,
|
||||
mock_advance_cursor,
|
||||
):
|
||||
initial_record = self._build_record(record_id=1000012, product_name=self.existing_product.name)
|
||||
updated_record = self._build_record(record_id=1000013, product_name=self.existing_product.name)
|
||||
updated_record['area'] = ''
|
||||
mock_advance_cursor.return_value = {'updated': True}
|
||||
|
||||
mock_fetch_records.return_value = self._build_payload([initial_record])
|
||||
first_result = sync_external_printing_records.run(limit=100)
|
||||
|
||||
mock_fetch_records.return_value = self._build_payload([updated_record])
|
||||
second_result = sync_external_printing_records.run(limit=100)
|
||||
|
||||
self.assertEqual(first_result['orders_created'], 1)
|
||||
self.assertEqual(second_result['orders_updated'], 1)
|
||||
self.assertEqual(mock_fetch_image.call_count, 0)
|
||||
|
||||
order = printing_models.PrintingOrder.objects.get(external_order_id='KD20432358')
|
||||
self.assertEqual(order.area, '')
|
||||
|
||||
@@ -37,6 +37,7 @@ from .views.shipment import (
|
||||
SalesItemByCustomerView,
|
||||
SalesItemByPrintingOrderView,
|
||||
SalesItemCreateView,
|
||||
ShipmentStatusUpdateView,
|
||||
ShipmentDeliveryBindShipmentsView,
|
||||
ShipmentDeliveryCancelView,
|
||||
ShipmentDeliveryDetailView,
|
||||
@@ -308,6 +309,11 @@ urlpatterns = [
|
||||
ShipmentDetailView.as_view(),
|
||||
name="shipment_detail",
|
||||
),
|
||||
path(
|
||||
"shipment/shipments/<int:pk>/status/",
|
||||
ShipmentStatusUpdateView.as_view(),
|
||||
name="shipment_status_update",
|
||||
),
|
||||
path(
|
||||
"shipment/shipments/external/",
|
||||
ShipmentExternalCreateView.as_view(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
| `KhID` | 外部客户 ID | `PrintingOrder.external_customer_id` | 原样保存 |
|
||||
| `customer.KhName` | 外部客户名 | `PrintingOrder.external_customer_name` | 原样保存 |
|
||||
| `HpName` | 布料名 | `PrintingOrder.fabric` | 原样保存到订单面料字段 |
|
||||
| `area` | 地区/区域 | `PrintingOrder.area` | 原样保存;取不到时回退为空字符串 `""` |
|
||||
| `CaoZY` | 外部操作员/业务员 | `PrintingOrder.created_by` / `PrintingOrder.external_employee_name` | 若能匹配到内部员工且员工绑定了系统用户,则 `created_by` 取该用户,`external_employee_name` 置空;否则 `created_by` 取固定同步用户,`external_employee_name` 保留原文 |
|
||||
| `SHDZ` | 布料来源 + 工艺 | `PrintingOrder.fabric_source` / `PrintingOrder.craft` | 以空白字符 split;第 1 段为 `fabric_source`,剩余重新拼接为 `craft` |
|
||||
| `SeHao` | 幅宽 | `PrintingOrder.width` | 原样保存 |
|
||||
@@ -52,6 +53,7 @@
|
||||
补充说明:
|
||||
|
||||
- 外部 `HpName` 已作为布料名使用,直接写入 `PrintingOrder.fabric`
|
||||
- 外部 `area` 直接写入 `PrintingOrder.area`;若外部未返回或为空,则统一写入空字符串
|
||||
- `BianHaoKD`、`RiQi` 等当前未单独属性化的字段,保留在 `external_raw` 中
|
||||
- `BianHaoKD` 当前样例值类似 `"1.27"`,不按日期强解析
|
||||
|
||||
|
||||
164
docs/sales_item_delete_api.md
Normal file
164
docs/sales_item_delete_api.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# SalesItem 修改与删除 API 文档
|
||||
|
||||
本文档说明 `SalesItem` 销售品的修改与软删除接口。
|
||||
|
||||
## 修改接口
|
||||
|
||||
### 接口信息
|
||||
|
||||
- URL: `/api/v1/shipment/sales-items/{id}/`
|
||||
- Method: `PATCH`
|
||||
- 认证: 需要登录(JWT Token)
|
||||
|
||||
说明:
|
||||
|
||||
- 该接口仅允许修改部分非关系字段
|
||||
- 当前允许修改的字段只有:
|
||||
- `quantity`
|
||||
- `remark`
|
||||
- `position`
|
||||
- 不允许修改 `name`、`unit`、`shipment`、`customer_id`、`printing_job_id` 等字段
|
||||
- 成功修改后会自动写入 `SalesItemChangeRecord`
|
||||
|
||||
### 请求体示例
|
||||
|
||||
```json
|
||||
{
|
||||
"quantity": "120.50",
|
||||
"remark": "补充备注",
|
||||
"position": "A3-02"
|
||||
}
|
||||
```
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 15,
|
||||
"name": "测试销售品",
|
||||
"quantity": "120.50",
|
||||
"unit": 1,
|
||||
"unit_display": "米",
|
||||
"position": "A3-02",
|
||||
"remark": "补充备注",
|
||||
"printing_job_id": 123,
|
||||
"printing_order_id": 45,
|
||||
"external_order_id": "EXT-2026-001",
|
||||
"customer_id": 8,
|
||||
"customer_name": "客户A",
|
||||
"shipment_id": null,
|
||||
"shipment_date": null,
|
||||
"created_at": "2026-04-06T10:00:00+08:00",
|
||||
"created_by_id": 3,
|
||||
"created_by_name": "测试员工",
|
||||
"product_image_url": null
|
||||
}
|
||||
```
|
||||
|
||||
### 修改审计记录
|
||||
|
||||
每次成功修改时,会新增一条 `SalesItemChangeRecord`,记录:
|
||||
|
||||
- `sales_item`
|
||||
- `operator`
|
||||
- `operated_at`
|
||||
- `before_values`
|
||||
- `after_values`
|
||||
|
||||
### 错误响应示例
|
||||
|
||||
#### 1. 传入了不允许修改的字段
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "销售品仅允许修改以下字段: position, quantity, remark;不支持字段: customer_id, name"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 数量格式非法
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "数量 abc 格式无效: [具体异常信息]"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 对象不存在或无权访问
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Not found."
|
||||
}
|
||||
```
|
||||
|
||||
## 删除接口
|
||||
|
||||
### 接口信息
|
||||
|
||||
- URL: `/api/v1/shipment/sales-items/{id}/`
|
||||
- Method: `DELETE`
|
||||
- 认证: 需要登录(JWT Token)
|
||||
|
||||
说明:
|
||||
|
||||
- 该接口为软删除,不会物理删除数据库记录
|
||||
- 删除后销售品会从常规查询接口中隐藏
|
||||
- 删除操作需要独立 Django 权限:`shipment.soft_delete_salesitem`
|
||||
|
||||
### 软删除字段
|
||||
|
||||
`SalesItem` 通过以下字段记录删除信息:
|
||||
|
||||
- `delete_at`: 删除时间
|
||||
- `delete_by`: 删除人
|
||||
|
||||
### 权限要求
|
||||
|
||||
调用方需要具备:
|
||||
|
||||
- `shipment.soft_delete_salesitem`
|
||||
|
||||
无该权限时返回 `403 Forbidden`。
|
||||
|
||||
### 请求示例
|
||||
|
||||
```http
|
||||
DELETE /api/v1/shipment/sales-items/15/
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "销售品已标记为删除"
|
||||
}
|
||||
```
|
||||
|
||||
### 行为说明
|
||||
|
||||
- 软删除后,`SalesItem` 不再出现在以下常规接口结果中:
|
||||
- 销售品详情
|
||||
- 按客户查询销售品
|
||||
- 按生产订单查询销售品
|
||||
- 未出货销售品客户反查
|
||||
- 出货单详情 / 列表中的嵌套销售品
|
||||
- 重复删除当前实现保持幂等;由于对象在常规查询中已隐藏,后续再次调用通常会得到 `404`
|
||||
|
||||
### 错误响应示例
|
||||
|
||||
#### 1. 无权限
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "没有权限删除销售品"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 对象不存在或无权访问
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Not found."
|
||||
}
|
||||
```
|
||||
@@ -5,6 +5,8 @@
|
||||
## 目录
|
||||
|
||||
- [查询出货单](#查询出货单)
|
||||
- [出货单状态流转接口](./shipment_status_api.md)
|
||||
- [销售品删除接口](./sales_item_delete_api.md)
|
||||
- [创建出货单](#创建出货单)
|
||||
- [查询有待出货销售品的客户](#查询有待出货销售品的客户)
|
||||
- [按客户查询销售品](#按客户查询销售品)
|
||||
|
||||
132
docs/shipment_status_api.md
Normal file
132
docs/shipment_status_api.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Shipment Status API 文档
|
||||
|
||||
本文档仅说明 `Shipment` 出货单状态流转接口。
|
||||
|
||||
## 接口信息
|
||||
|
||||
- URL: `/api/v1/shipment/shipments/{id}/status/`
|
||||
- Method: `PATCH` / `PUT`
|
||||
- 认证: 需要登录(JWT Token)
|
||||
|
||||
说明:
|
||||
|
||||
- 该接口只负责修改 `Shipment.status`
|
||||
- 业务字段修改仍然使用 `/api/v1/shipment/shipments/{id}/`
|
||||
- `PATCH` 与 `PUT` 当前行为一致,都是按请求体中的 `status` 执行状态流转
|
||||
|
||||
## 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 2
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `status`: 目标状态
|
||||
- `1 = 草稿(未发布)`
|
||||
- `2 = 已发布`
|
||||
- `3 = 已取消`
|
||||
- `4 = 已驳回`
|
||||
- `5 = 已审核`
|
||||
|
||||
## 状态机规则
|
||||
|
||||
- `草稿(未发布)` 只能流转到 `已发布`
|
||||
- `已发布` 可以流转到 `已审核` / `已驳回` / `已取消`
|
||||
- `已驳回` 可以流转到 `已审核` / `已取消`
|
||||
- `已审核` 可以流转到 `已取消`
|
||||
- `已取消` 不可再流转
|
||||
- 重复设置同一状态时保持幂等
|
||||
|
||||
附加规则:
|
||||
|
||||
- 进入 `已审核` 时,接口会自动将当前 `request.user` 写入 `approved_by`
|
||||
- 进入 `已取消` 时,接口会自动将当前 `request.user` 写入 `cancelled_by`
|
||||
- 每次成功状态流转都会更新 `status_modified_at`
|
||||
|
||||
## 请求示例
|
||||
|
||||
### 1. 草稿发布
|
||||
|
||||
```http
|
||||
PATCH /api/v1/shipment/shipments/12/status/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 已发布审核
|
||||
|
||||
```http
|
||||
PUT /api/v1/shipment/shipments/12/status/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": 5
|
||||
}
|
||||
```
|
||||
|
||||
## 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"merchant_id": 1,
|
||||
"merchant_name": "测试印花厂",
|
||||
"customer": 8,
|
||||
"customer_name": "客户A",
|
||||
"shipment_date": "2026-04-06",
|
||||
"address": "",
|
||||
"contact_name": "",
|
||||
"contact_phone": "",
|
||||
"area": "",
|
||||
"remark": "",
|
||||
"status": 5,
|
||||
"status_display": "已审核",
|
||||
"external_id": null,
|
||||
"status_modified_at": "2026-04-06T14:30:00+08:00",
|
||||
"cancelled_by_id": null,
|
||||
"cancelled_by_name": null,
|
||||
"approved_by_id": 3,
|
||||
"approved_by_name": "测试员工",
|
||||
"items_count": 2,
|
||||
"sales_items": [],
|
||||
"external_finished_products": [],
|
||||
"created_by_id": 3,
|
||||
"created_by_name": "测试员工",
|
||||
"created_at": "2026-04-06T10:00:00+08:00",
|
||||
"updated_at": "2026-04-06T14:30:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 错误响应示例
|
||||
|
||||
### 1. 非法流转
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "不允许将出货单状态从 草稿(未发布) 修改为 已审核"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 越权或对象不存在
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Not found."
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 请求体不合法
|
||||
|
||||
```json
|
||||
{
|
||||
"status": [
|
||||
"\"99\" is not a valid choice."
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,11 @@
|
||||
from django.contrib import admin
|
||||
from .models import ExternalFinishedProduct, Shipment, SalesItem, ShipmentDelivery
|
||||
from .models import (
|
||||
ExternalFinishedProduct,
|
||||
SalesItem,
|
||||
SalesItemChangeRecord,
|
||||
Shipment,
|
||||
ShipmentDelivery,
|
||||
)
|
||||
|
||||
|
||||
class SalesItemInline(admin.TabularInline):
|
||||
@@ -64,10 +70,10 @@ class ShipmentAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(SalesItem)
|
||||
class SalesItemAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'created_at']
|
||||
list_filter = ['unit', 'created_at', ('shipment', admin.EmptyFieldListFilter)]
|
||||
list_display = ['id', 'name', 'quantity', 'unit', 'position', 'shipment_status', 'printing_job_id', 'customer_id', 'created_by', 'delete_at', 'delete_by', 'created_at']
|
||||
list_filter = ['unit', 'created_at', 'delete_at', ('shipment', admin.EmptyFieldListFilter)]
|
||||
search_fields = ['name', 'shipment__customer__name', 'position', 'remark']
|
||||
readonly_fields = ['created_at', 'updated_at', 'created_by']
|
||||
readonly_fields = ['created_at', 'updated_at', 'created_by', 'delete_at', 'delete_by']
|
||||
raw_id_fields = ['shipment']
|
||||
|
||||
def shipment_status(self, obj):
|
||||
@@ -96,6 +102,32 @@ class ExternalFinishedProductAdmin(admin.ModelAdmin):
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(SalesItemChangeRecord)
|
||||
class SalesItemChangeRecordAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"sales_item",
|
||||
"operator",
|
||||
"operated_at",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["operated_at", "created_at"]
|
||||
search_fields = [
|
||||
"sales_item__name",
|
||||
"operator__username",
|
||||
"operator__employee__name",
|
||||
]
|
||||
readonly_fields = [
|
||||
"sales_item",
|
||||
"operator",
|
||||
"operated_at",
|
||||
"before_values",
|
||||
"after_values",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(ShipmentDelivery)
|
||||
class ShipmentDeliveryAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
|
||||
59
shipment/migrations/0017_salesitemchangerecord.py
Normal file
59
shipment/migrations/0017_salesitemchangerecord.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("shipment", "0016_shipmentdelivery_contact_phone_and_vehicle_capacity"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SalesItemChangeRecord",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
|
||||
("operated_at", models.DateTimeField(verbose_name="操作时间")),
|
||||
("before_values", models.JSONField(blank=True, default=dict, verbose_name="修改前的值")),
|
||||
("after_values", models.JSONField(blank=True, default=dict, verbose_name="修改目标值")),
|
||||
(
|
||||
"operator",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sales_item_change_records",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="操作者",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sales_item",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="change_records",
|
||||
to="shipment.salesitem",
|
||||
verbose_name="销售品",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "销售品修改记录",
|
||||
"verbose_name_plural": "销售品修改记录",
|
||||
"db_table": "sales_item_change_record",
|
||||
"ordering": ["-operated_at", "-id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
40
shipment/migrations/0018_salesitem_soft_delete_fields.py
Normal file
40
shipment/migrations/0018_salesitem_soft_delete_fields.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("shipment", "0017_salesitemchangerecord"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="salesitem",
|
||||
name="delete_at",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="删除时间"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="salesitem",
|
||||
name="delete_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="deleted_sales_items",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="删除人",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="salesitem",
|
||||
options={
|
||||
"ordering": ["id"],
|
||||
"permissions": [("soft_delete_salesitem", "Can soft delete sales item")],
|
||||
"verbose_name": "销售品",
|
||||
"verbose_name_plural": "销售品",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -303,11 +303,29 @@ class SalesItem(ModelBase):
|
||||
verbose_name='备注'
|
||||
)
|
||||
|
||||
delete_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='删除时间',
|
||||
)
|
||||
|
||||
delete_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='deleted_sales_items',
|
||||
verbose_name='删除人',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'sales_item'
|
||||
verbose_name = '销售品'
|
||||
verbose_name_plural = '销售品'
|
||||
ordering = ['id']
|
||||
permissions = [
|
||||
('soft_delete_salesitem', 'Can soft delete sales item'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} x {self.quantity} {self.get_unit_display()}'
|
||||
@@ -342,6 +360,55 @@ class SalesItem(ModelBase):
|
||||
return None
|
||||
|
||||
|
||||
class SalesItemChangeRecord(ModelBase):
|
||||
"""
|
||||
销售品修改记录。
|
||||
|
||||
记录允许修改字段的变更前后值,以及操作者与操作时间。
|
||||
"""
|
||||
|
||||
sales_item = models.ForeignKey(
|
||||
SalesItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="change_records",
|
||||
verbose_name="销售品",
|
||||
)
|
||||
|
||||
operator = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="sales_item_change_records",
|
||||
verbose_name="操作者",
|
||||
)
|
||||
|
||||
operated_at = models.DateTimeField(
|
||||
verbose_name="操作时间",
|
||||
)
|
||||
|
||||
before_values = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name="修改前的值",
|
||||
)
|
||||
|
||||
after_values = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name="修改目标值",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "sales_item_change_record"
|
||||
verbose_name = "销售品修改记录"
|
||||
verbose_name_plural = "销售品修改记录"
|
||||
ordering = ["-operated_at", "-id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"SalesItemChangeRecord #{self.id} - sales_item={self.sales_item_id}"
|
||||
|
||||
|
||||
class ShipmentDelivery(ModelBase):
|
||||
"""
|
||||
送货单
|
||||
|
||||
@@ -4,6 +4,7 @@ Shipment 模块业务逻辑层
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import List
|
||||
|
||||
from django.db import transaction
|
||||
@@ -14,6 +15,7 @@ from django.utils import timezone
|
||||
from shipment.models import (
|
||||
ExternalFinishedProduct,
|
||||
SalesItem,
|
||||
SalesItemChangeRecord,
|
||||
Shipment,
|
||||
ShipmentDelivery,
|
||||
ShipmentDeliveryStatus,
|
||||
@@ -82,6 +84,7 @@ def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet:
|
||||
base_sales_items = SalesItem.objects.filter(
|
||||
merchant=merchant,
|
||||
shipment__isnull=True,
|
||||
delete_at__isnull=True,
|
||||
customer_id=OuterRef("pk"),
|
||||
)
|
||||
count_subquery = (
|
||||
@@ -153,6 +156,7 @@ def get_sales_items_by_printing_order(
|
||||
|
||||
# 2. 查询 SalesItem,过滤 printing_job_id 在这些 job_ids 中
|
||||
queryset = SalesItem.objects.filter(printing_job_id__in=list(job_ids))
|
||||
queryset = queryset.filter(delete_at__isnull=True)
|
||||
|
||||
# 3. 根据参数决定是否过滤已出货的销售品
|
||||
if not include_already_has_shipment:
|
||||
@@ -176,6 +180,7 @@ def get_sales_items_by_customer(
|
||||
queryset = SalesItem.objects.filter(
|
||||
merchant=merchant,
|
||||
customer_id=customer_id,
|
||||
delete_at__isnull=True,
|
||||
)
|
||||
|
||||
if external_order_id:
|
||||
@@ -193,6 +198,91 @@ def get_sales_items_by_customer(
|
||||
return queryset.select_related("shipment").order_by("id")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_sales_item(
|
||||
sales_item: SalesItem,
|
||||
*,
|
||||
quantity: str | Decimal | None = None,
|
||||
remark: str | None = None,
|
||||
position: str | None = None,
|
||||
operator=None,
|
||||
) -> SalesItem:
|
||||
"""
|
||||
更新销售品的非关系字段。
|
||||
|
||||
当前仅允许修改:
|
||||
- quantity
|
||||
- remark
|
||||
- position
|
||||
"""
|
||||
changes: dict[str, tuple[object, object]] = {}
|
||||
|
||||
if quantity is not None:
|
||||
try:
|
||||
normalized_quantity = Decimal(str(quantity))
|
||||
except (InvalidOperation, ValueError, TypeError) as exc:
|
||||
raise ValueError(f"数量 {quantity} 格式无效: {exc}")
|
||||
if normalized_quantity != sales_item.quantity:
|
||||
changes["quantity"] = (sales_item.quantity, normalized_quantity)
|
||||
sales_item.quantity = normalized_quantity
|
||||
|
||||
if remark is not None:
|
||||
normalized_remark = remark or ""
|
||||
if normalized_remark != sales_item.remark:
|
||||
changes["remark"] = (sales_item.remark, normalized_remark)
|
||||
sales_item.remark = normalized_remark
|
||||
|
||||
if position is not None:
|
||||
normalized_position = position or ""
|
||||
if normalized_position != sales_item.position:
|
||||
changes["position"] = (sales_item.position, normalized_position)
|
||||
sales_item.position = normalized_position
|
||||
|
||||
if not changes:
|
||||
return sales_item
|
||||
|
||||
sales_item.save(update_fields=[*changes.keys(), "updated_at"])
|
||||
|
||||
def _serialize_value(value):
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
SalesItemChangeRecord.objects.create(
|
||||
sales_item=sales_item,
|
||||
operator=operator,
|
||||
operated_at=timezone.now(),
|
||||
before_values={
|
||||
field: _serialize_value(old_value)
|
||||
for field, (old_value, _) in changes.items()
|
||||
},
|
||||
after_values={
|
||||
field: _serialize_value(new_value)
|
||||
for field, (_, new_value) in changes.items()
|
||||
},
|
||||
)
|
||||
|
||||
return sales_item
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_sales_item(
|
||||
sales_item: SalesItem,
|
||||
*,
|
||||
deleted_by=None,
|
||||
) -> SalesItem:
|
||||
"""
|
||||
软删除销售品。
|
||||
"""
|
||||
if sales_item.delete_at is not None:
|
||||
return sales_item
|
||||
|
||||
sales_item.delete_at = timezone.now()
|
||||
sales_item.delete_by = deleted_by
|
||||
sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"])
|
||||
return sales_item
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_shipment_delivery(
|
||||
*,
|
||||
@@ -579,7 +669,7 @@ def create_shipment(
|
||||
from printing.models import PrintingJob
|
||||
|
||||
# 查询销售品
|
||||
sales_items = SalesItem.objects.filter(id__in=sales_item_ids)
|
||||
sales_items = SalesItem.objects.filter(id__in=sales_item_ids, delete_at__isnull=True)
|
||||
found_ids = set(sales_items.values_list("id", flat=True))
|
||||
missing_ids = set(sales_item_ids) - found_ids
|
||||
|
||||
@@ -642,7 +732,7 @@ def create_shipment(
|
||||
# 关联销售品
|
||||
if sales_item_ids:
|
||||
updated = SalesItem.objects.filter(
|
||||
id__in=sales_item_ids, merchant=merchant
|
||||
id__in=sales_item_ids, merchant=merchant, delete_at__isnull=True
|
||||
).update(shipment=shipment)
|
||||
if updated != len(sales_item_ids):
|
||||
raise ValueError("存在不属于当前商户的销售品,无法关联到出货单")
|
||||
@@ -752,7 +842,6 @@ def create_sales_item(
|
||||
Raises:
|
||||
ValueError: 如果生产任务不存在或不属于当前商户
|
||||
"""
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from printing.models import PrintingJob
|
||||
|
||||
# 获取当前用户的商户
|
||||
|
||||
Reference in New Issue
Block a user