1
0
forked from erp-dev/erp

feat: shipment change && version modelize

This commit is contained in:
2026-07-09 23:03:22 +08:00
parent 36e4bb6de6
commit 48e4782e1e
23 changed files with 947 additions and 36 deletions

View File

@@ -7,6 +7,7 @@ from rest_framework import serializers
from rest_framework.fields import empty
from api_v1.models import UploadedFile
from api_v1.external_datetime import parse_external_china_datetime
from api_v1.utils.media import build_public_media_url
from api_v1.views.shipment.serializers import SalesItemSerializer
from printing import models
@@ -54,7 +55,11 @@ def resolve_printing_order_kd_riqi(obj):
value = getattr(obj, 'kd_riqi', None)
if value is not None:
return serializers.DateTimeField().to_representation(value)
return _resolve_printing_order_external_first_record_field(obj, 'KdRiQi')
raw_value = _resolve_printing_order_external_first_record_field(obj, 'KdRiQi')
parsed = parse_external_china_datetime(raw_value)
if parsed is None:
return raw_value
return serializers.DateTimeField().to_representation(parsed)
def _serialize_plate_images(raw_value, request):
@@ -354,13 +359,24 @@ class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
],
help_text="出货日期时间(可仅传 YYYY-MM-DD,将自动视为 00:00:00)",
)
kd_riqi = serializers.DateTimeField(
required=False,
allow_null=True,
input_formats=[
"iso-8601",
"%Y-%m-%d",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
],
help_text="外部开单日期时间(可仅传 YYYY-MM-DD,将自动视为 00:00:00)",
)
class Meta:
model = models.PrintingOrder
fields = [
'id', 'customer', 'fabric', 'width', 'is_urgent', 'area', 'address',
'fabric_source', 'is_fabric_received', 'craft', 'description',
'outgoing_date', 'curve', 'new_curve', 'position',
'outgoing_date', 'kd_riqi', 'curve', 'new_curve', 'position',
'printing_warn', 'rolling_warn', 'production_warn', 'is_invalid',
'process', 'external_order_id',
]

View File

@@ -94,6 +94,7 @@ class PrintingOrderAPITestCase(TestCase):
'craft': '活性印花',
'description': '测试订单描述',
'outgoing_date': '2025-11-20',
'kd_riqi': '2025-11-19 13:14:15',
'printing_warn': '注意颜色',
'rolling_warn': '注意温度',
'production_warn': '质量检查'
@@ -120,6 +121,9 @@ class PrintingOrderAPITestCase(TestCase):
self.assertEqual(str(local_dt.date()), '2025-11-20')
self.assertEqual(local_dt.hour, 0)
self.assertEqual(local_dt.minute, 0)
self.assertIsNotNone(order.kd_riqi)
local_kd_riqi = timezone.localtime(order.kd_riqi)
self.assertEqual(local_kd_riqi.isoformat(), '2025-11-19T13:14:15+08:00')
def test_filter_outgoing_date_to_includes_whole_day(self):
"""
@@ -284,14 +288,14 @@ class PrintingOrderAPITestCase(TestCase):
self.assertEqual(item['external_customer_name'], '李泽柔')
self.assertEqual(item['external_employee_name'], '丽容')
self.assertEqual(item['bianhao_kd'], '7.07')
self.assertEqual(item['kd_riqi'], '2026-07-06T21:48:58Z')
self.assertEqual(item['kd_riqi'], '2026-07-06T21:48:58+08:00')
detail = self.client.get(f'/api/v1/printing-orders/{order1.id}/')
self.assertEqual(detail.status_code, status.HTTP_200_OK)
self.assertEqual(detail.data['external_order_id'], 'KD20410611')
self.assertEqual(detail.data['external_customer_id'], 'KH00991')
self.assertEqual(detail.data['bianhao_kd'], '7.07')
self.assertEqual(detail.data['kd_riqi'], '2026-07-06T21:48:58Z')
self.assertEqual(detail.data['kd_riqi'], '2026-07-06T21:48:58+08:00')
def test_bianhao_kd_returns_null_without_external_order_id(self):
order = printing_models.PrintingOrder.objects.create(
@@ -342,6 +346,39 @@ class PrintingOrderAPITestCase(TestCase):
result_ids = [item['id'] for item in response.data['results']]
self.assertEqual(result_ids, [older.id, newer.id])
self.assertEqual(response.data['results'][0]['kd_riqi'], '2026-07-01T18:00:00+08:00')
def test_order_by_kd_riqi_puts_nulls_last(self):
null_order = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
fabric='空开单时间',
width='150cm',
external_order_id='KD-NULL',
)
older = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
fabric='旧开单时间',
width='150cm',
external_order_id='KD-OLDER-NULLS-LAST',
kd_riqi=datetime(2026, 7, 1, 10, 0, tzinfo=dt_timezone.utc),
)
newer = printing_models.PrintingOrder.objects.create(
merchant=self.merchant,
customer=self.customer,
fabric='新开单时间',
width='150cm',
external_order_id='KD-NEWER-NULLS-LAST',
kd_riqi=datetime(2026, 7, 2, 10, 0, tzinfo=dt_timezone.utc),
)
asc_response = self.client.get('/api/v1/printing-orders/?ordering=kd_riqi&limit=3')
desc_response = self.client.get('/api/v1/printing-orders/?ordering=-kd_riqi&limit=3')
self.assertEqual(asc_response.status_code, status.HTTP_200_OK)
self.assertEqual(desc_response.status_code, status.HTTP_200_OK)
self.assertEqual([item['id'] for item in asc_response.data['results']], [older.id, newer.id, null_order.id])
self.assertEqual([item['id'] for item in desc_response.data['results']], [newer.id, older.id, null_order.id])
def test_retrieve_printing_order(self):
"""测试获取订单详情"""
@@ -418,7 +455,8 @@ class PrintingOrderAPITestCase(TestCase):
patch_data = {
'is_urgent': True,
'craft': '新工艺'
'craft': '新工艺',
'kd_riqi': '2025-11-21 09:30:00',
}
response = self.client.patch(
@@ -431,6 +469,7 @@ class PrintingOrderAPITestCase(TestCase):
order.refresh_from_db()
self.assertEqual(order.is_urgent, True)
self.assertEqual(order.craft, '新工艺')
self.assertEqual(timezone.localtime(order.kd_riqi).isoformat(), '2025-11-21T09:30:00+08:00')
self.assertEqual(order.fabric, '原布料') # 未修改字段保持不变
def test_delete_printing_order_forbidden(self):

View File

@@ -10,7 +10,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from django.core.exceptions import ValidationError
from django.db.models import CharField, Exists, OuterRef, Prefetch, Subquery
from django.db.models import CharField, Exists, F, OuterRef, Prefetch, Subquery
from django.db.models.functions import Cast, Coalesce
from django.views.decorators.cache import cache_page
from django.views.decorators.http import condition
@@ -38,6 +38,23 @@ from .serializers import (
from .mixins import CustomerVisibilityFilterMixin
class PrintingOrderOrderingFilter(filters.OrderingFilter):
def filter_queryset(self, request, queryset, view):
ordering = self.get_ordering(request, queryset, view)
if not ordering:
return queryset
expressions = []
for field in ordering:
if field == "kd_riqi":
expressions.append(F("kd_riqi").asc(nulls_last=True))
elif field == "-kd_riqi":
expressions.append(F("kd_riqi").desc(nulls_last=True))
else:
expressions.append(field)
return queryset.order_by(*expressions)
class IsPrintingFactory(BasePermission):
"""自定义权限类,允许印染工厂用户访问"""
@@ -276,7 +293,7 @@ class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
PrintingOrderOrderingFilter,
]
filterset_class = PrintingOrderFilterSet
search_fields = ["customer__name", "fabric", "area", "craft", "description"]

View File

@@ -14,6 +14,41 @@ from shipment.models import (
)
SHIPMENT_STAGE_MISSING_ADDRESS = "missing_address"
SHIPMENT_STAGE_DELIVERABLE = "deliverable"
SHIPMENT_STAGE_SCHEDULED = "scheduled"
SHIPMENT_STAGE_DELIVERED = "delivered"
SHIPMENT_STAGE_CANCELLED = "cancelled"
SHIPMENT_STAGE_DISPLAY = {
SHIPMENT_STAGE_MISSING_ADDRESS: "待补地址",
SHIPMENT_STAGE_DELIVERABLE: "可送货",
SHIPMENT_STAGE_SCHEDULED: "已排车",
SHIPMENT_STAGE_DELIVERED: "已送达",
SHIPMENT_STAGE_CANCELLED: "已取消",
}
def resolve_shipment_stage(shipment: Shipment) -> str | None:
if shipment.status == ShipmentStatus.CANCELLED:
return SHIPMENT_STAGE_CANCELLED
delivery = getattr(shipment, "delivery", None)
if delivery is not None:
if delivery.status == ShipmentDeliveryStatus.DELIVERED:
return SHIPMENT_STAGE_DELIVERED
if delivery.status in {
ShipmentDeliveryStatus.PENDING,
ShipmentDeliveryStatus.IN_TRANSIT,
}:
return SHIPMENT_STAGE_SCHEDULED
return None
if (shipment.address or "").strip():
return SHIPMENT_STAGE_DELIVERABLE
return SHIPMENT_STAGE_MISSING_ADDRESS
def _build_nested_sales_item_context(items):
customer_ids = {item.customer_id for item in items if item.customer_id}
printing_job_ids = {item.printing_job_id for item in items if item.printing_job_id}
@@ -128,6 +163,8 @@ class ShipmentSerializer(serializers.ModelSerializer):
)
approved_by_name = serializers.SerializerMethodField()
status_display = serializers.CharField(source="get_status_display", read_only=True)
shipment_stage = serializers.SerializerMethodField()
shipment_stage_display = serializers.SerializerMethodField()
external_finished_products_count = serializers.SerializerMethodField()
merchant_id = serializers.IntegerField(source="merchant.id", read_only=True)
merchant_name = serializers.CharField(source="merchant.name", read_only=True)
@@ -157,6 +194,8 @@ class ShipmentSerializer(serializers.ModelSerializer):
"remark",
"status",
"status_display",
"shipment_stage",
"shipment_stage_display",
"external_id",
"geo_coordinates",
"extra",
@@ -191,6 +230,13 @@ class ShipmentSerializer(serializers.ModelSerializer):
def get_items_count(self, obj):
return obj.items.filter(delete_at__isnull=True).count()
def get_shipment_stage(self, obj):
return resolve_shipment_stage(obj)
def get_shipment_stage_display(self, obj):
stage = resolve_shipment_stage(obj)
return SHIPMENT_STAGE_DISPLAY.get(stage)
def get_order_description(self, obj):
return _resolve_shipment_order_description(obj)

View File

@@ -2430,6 +2430,79 @@ class ShipmentQueryAPITestCase(TestCase):
self.assertEqual(data["results"][0]["id"], self.shipment1.id)
self.assertEqual(data["results"][0]["delivery_id"], delivery.id)
def test_list_shipments_supports_shipment_stage_filter_and_fields(self):
self.shipment1.address = ""
self.shipment1.save(update_fields=["address", "updated_at"])
deliverable = shipment_models.Shipment.objects.create(
merchant=self.merchant1,
customer=self.customer1,
shipment_date="2026-01-20",
created_by=self.user1,
address="绍兴市测试路 8 号",
)
scheduled_delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant1,
driver_name="排车司机",
vehicle_trip="STAGE-SCHEDULED",
status=shipment_models.ShipmentDeliveryStatus.PENDING,
created_by=self.user1,
)
scheduled = shipment_models.Shipment.objects.create(
merchant=self.merchant1,
customer=self.customer1,
shipment_date="2026-01-21",
created_by=self.user1,
address="已排车地址",
delivery=scheduled_delivery,
)
delivered_delivery = shipment_models.ShipmentDelivery.objects.create(
merchant=self.merchant1,
driver_name="送达司机",
vehicle_trip="STAGE-DELIVERED",
status=shipment_models.ShipmentDeliveryStatus.DELIVERED,
created_by=self.user1,
)
delivered = shipment_models.Shipment.objects.create(
merchant=self.merchant1,
customer=self.customer1,
shipment_date="2026-01-22",
created_by=self.user1,
address="已送达地址",
delivery=delivered_delivery,
)
cancelled = shipment_models.Shipment.objects.create(
merchant=self.merchant1,
customer=self.customer1,
shipment_date="2026-01-23",
created_by=self.user1,
address="取消地址",
status=shipment_models.ShipmentStatus.CANCELLED,
)
cases = [
("missing_address", self.shipment1.id, "待补地址"),
("deliverable", deliverable.id, "可送货"),
("scheduled", scheduled.id, "已排车"),
("delivered", delivered.id, "已送达"),
("cancelled", cancelled.id, "已取消"),
]
for stage, expected_id, expected_display in cases:
with self.subTest(stage=stage):
resp = self.client.get(f"/api/v1/shipment/shipments/?shipment_stage={stage}&limit=1")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = resp.json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], expected_id)
self.assertEqual(data["results"][0]["shipment_stage"], stage)
self.assertEqual(data["results"][0]["shipment_stage_display"], expected_display)
def test_list_shipments_rejects_invalid_shipment_stage(self):
resp = self.client.get("/api/v1/shipment/shipments/?shipment_stage=unknown")
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("shipment_stage", resp.json())
def test_retrieve_shipment_success(self):
resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)

View File

@@ -3,6 +3,7 @@ Shipment API ViewSet
"""
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.permissions import IsAuthenticated
@@ -11,7 +12,7 @@ from rest_framework.views import APIView
from django.utils.dateparse import parse_date, parse_datetime
from flower.viewsets import LimitedLimitOffsetPagination
from shipment.models import Shipment, ShipmentDelivery
from shipment.models import Shipment, ShipmentDelivery, ShipmentDeliveryStatus, ShipmentStatus
from .serializers import (
SalesItemDetailSerializer,
@@ -30,9 +31,23 @@ from .serializers import (
ShipmentCreateExternalSerializer,
ShipmentSalesItemCustomerSerializer,
ShipmentUpdateSerializer,
SHIPMENT_STAGE_CANCELLED,
SHIPMENT_STAGE_DELIVERABLE,
SHIPMENT_STAGE_DELIVERED,
SHIPMENT_STAGE_MISSING_ADDRESS,
SHIPMENT_STAGE_SCHEDULED,
)
SHIPMENT_STAGE_CHOICES = {
SHIPMENT_STAGE_MISSING_ADDRESS,
SHIPMENT_STAGE_DELIVERABLE,
SHIPMENT_STAGE_SCHEDULED,
SHIPMENT_STAGE_DELIVERED,
SHIPMENT_STAGE_CANCELLED,
}
def _build_sales_item_serializer_context(items):
customer_ids = {item.customer_id for item in items if item.customer_id}
printing_job_ids = {item.printing_job_id for item in items if item.printing_job_id}
@@ -125,13 +140,14 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
- status: 状态(1=草稿, 2=已发布, 3=已取消, 4=已驳回, 5=已审核)
- delivery_id: 送货单ID(仅当传入具体ID时过滤;null/空值不触发过滤)
- delivery_isnull: 是否仅查询未绑定/已绑定送货单的出货单(true/false)
- shipment_stage: 逻辑状态(missing_address/deliverable/scheduled/delivered/cancelled)
- external_id: 外部订单号(精确匹配)
- shipment_date_from: 出货日期起始(YYYY-MM-DD)
- shipment_date_to: 出货日期结束(YYYY-MM-DD,包含整天)
"""
qs = (
Shipment.objects.all()
.select_related("merchant", "customer", "created_by", "cancelled_by")
.select_related("merchant", "customer", "created_by", "cancelled_by", "delivery")
.prefetch_related(
"items",
"external_finished_products",
@@ -190,6 +206,35 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
elif normalized in {"0", "false", "no"}:
qs = qs.exclude(address="")
shipment_stage = self.request.query_params.get("shipment_stage")
if shipment_stage:
normalized_stage = shipment_stage.strip()
if normalized_stage not in SHIPMENT_STAGE_CHOICES:
allowed = ", ".join(sorted(SHIPMENT_STAGE_CHOICES))
raise ValidationError({"shipment_stage": f"shipment_stage 必须是 {allowed} 之一"})
if normalized_stage == SHIPMENT_STAGE_CANCELLED:
qs = qs.filter(status=ShipmentStatus.CANCELLED)
else:
qs = qs.exclude(status=ShipmentStatus.CANCELLED)
if normalized_stage == SHIPMENT_STAGE_MISSING_ADDRESS:
qs = qs.filter(delivery_id__isnull=True, address="")
elif normalized_stage == SHIPMENT_STAGE_DELIVERABLE:
qs = qs.filter(delivery_id__isnull=True).exclude(address="")
elif normalized_stage == SHIPMENT_STAGE_SCHEDULED:
qs = qs.filter(
delivery_id__isnull=False,
delivery__status__in=[
ShipmentDeliveryStatus.PENDING,
ShipmentDeliveryStatus.IN_TRANSIT,
],
)
elif normalized_stage == SHIPMENT_STAGE_DELIVERED:
qs = qs.filter(
delivery_id__isnull=False,
delivery__status=ShipmentDeliveryStatus.DELIVERED,
)
return qs.order_by("-created_at", "-id")
def get(self, request):