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

@@ -2,6 +2,7 @@ from django.contrib import admin
from django.contrib.admin import action
from api_v1.models import (
UploadedFile,
AppVersion,
DataSync,
MDYPlateOrderStaging,
MDYPlateOrderStagingTiiaUploadFailure,
@@ -32,6 +33,41 @@ class UploadedFileAdmin(admin.ModelAdmin):
backup_database.delay()
@admin.register(AppVersion)
class AppVersionAdmin(admin.ModelAdmin):
list_display = [
'id',
'version_display',
'is_current',
'force',
'publish_date',
'download_url',
'created_at',
]
list_filter = ['is_current', 'force', 'publish_date', 'created_at']
search_fields = ['download_url', 'message']
readonly_fields = ['download_url', 'created_at', 'updated_at']
date_hierarchy = 'created_at'
ordering = ['-is_current', '-created_at']
fields = [
'major',
'minor',
'build',
'package_file',
'download_url',
'force',
'publish_date',
'message',
'is_current',
'created_at',
'updated_at',
]
@admin.display(description='版本')
def version_display(self, obj):
return str(obj)
@admin.register(DataSync)
class DataSyncAdmin(admin.ModelAdmin):
list_display = ['id', 'table_name', 'page_index', 'page_size', 'total_count', 'synced_rows', 'last_ctime', 'last_rowid', 'note']

View File

@@ -0,0 +1,23 @@
from datetime import datetime
from django.utils import timezone
def parse_external_china_datetime(value: str | None):
"""外部 records 的时间字符串业务上是中国时间,即使误带 Z 也按本地时间解释。"""
if not value:
return None
text = str(value).strip()
if not text:
return None
normalized = text[:-1] if text.endswith('Z') else text
try:
dt = datetime.fromisoformat(normalized)
except ValueError:
return None
tz = timezone.get_current_timezone()
if timezone.is_aware(dt):
dt = dt.replace(tzinfo=None)
return timezone.make_aware(dt, tz)

View File

@@ -6,7 +6,7 @@ from flower.app_version import set_cached_app_version_payload
class Command(BaseCommand):
help = "更新 App 最新版本信息缓存"
help = "创建 App 版本并设为当前版本"
def add_arguments(self, parser):
parser.add_argument("--major", type=int, required=True)
@@ -14,6 +14,7 @@ class Command(BaseCommand):
parser.add_argument("--build", type=int, required=True)
parser.add_argument("--download-url", required=True)
parser.add_argument("--force", action="store_true", default=False)
parser.add_argument("--message", default="", help="更新说明")
parser.add_argument(
"--publish-date",
help="发布日期,格式 YYYY-MM-DD不传则使用当前日期",
@@ -34,5 +35,6 @@ class Command(BaseCommand):
download_url=options["download_url"],
force=options["force"],
publish_date=publish_date,
message=options["message"],
)
self.stdout.write(self.style.SUCCESS(str(payload)))

View File

@@ -0,0 +1,39 @@
import api_v1.models
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api_v1', '0017_mdyplateorderstagingqiniuimageuploadstate'),
]
operations = [
migrations.CreateModel(
name='AppVersion',
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='更新时间')),
('major', models.PositiveIntegerField(default=0, verbose_name='主版本号')),
('minor', models.PositiveIntegerField(default=0, verbose_name='次版本号')),
('build', models.PositiveIntegerField(default=0, verbose_name='构建号')),
('package_file', models.FileField(blank=True, help_text='可通过 Admin 上传,保存后会自动写入下载地址', max_length=500, null=True, upload_to=api_v1.models.app_version_file_path, verbose_name='安装包文件')),
('download_url', models.URLField(blank=True, max_length=2048, verbose_name='下载地址')),
('force', models.BooleanField(default=False, verbose_name='是否强制更新')),
('publish_date', models.DateField(default=django.utils.timezone.localdate, verbose_name='发布日期')),
('message', models.TextField(blank=True, verbose_name='更新说明')),
('is_current', models.BooleanField(db_index=True, default=False, verbose_name='当前版本')),
],
options={
'verbose_name': 'App版本',
'verbose_name_plural': 'App版本',
'ordering': ['-is_current', '-created_at'],
},
),
migrations.AddConstraint(
model_name='appversion',
constraint=models.UniqueConstraint(condition=models.Q(('is_current', True)), fields=('is_current',), name='uniq_current_app_version'),
),
]

View File

@@ -4,8 +4,10 @@ API v1 通用模型
import os
import uuid
from django.db import models
from django.db.models import Q
from django.contrib.auth import get_user_model
from django.db.models.fields.json import KeyTextTransform
from django.utils import timezone
from flower.common import ModelBase
from basic_info import models as basic_info_models
@@ -28,6 +30,53 @@ def upload_file_path(instance, filename):
return f"uploads/{now.year}/{now.month:02d}/{now.day:02d}/{random_filename}"
def app_version_file_path(instance, filename):
ext = os.path.splitext(filename)[1].lower()
random_filename = f"{uuid.uuid4().hex}{ext}"
return f"app_versions/{random_filename}"
class AppVersion(ModelBase):
major = models.PositiveIntegerField(default=0, verbose_name='主版本号')
minor = models.PositiveIntegerField(default=0, verbose_name='次版本号')
build = models.PositiveIntegerField(default=0, verbose_name='构建号')
package_file = models.FileField(
upload_to=app_version_file_path,
max_length=500,
blank=True,
null=True,
verbose_name='安装包文件',
help_text='可通过 Admin 上传,保存后会自动写入下载地址',
)
download_url = models.URLField(max_length=2048, blank=True, verbose_name='下载地址')
force = models.BooleanField(default=False, verbose_name='是否强制更新')
publish_date = models.DateField(default=timezone.localdate, verbose_name='发布日期')
message = models.TextField(blank=True, verbose_name='更新说明')
is_current = models.BooleanField(default=False, db_index=True, verbose_name='当前版本')
class Meta:
verbose_name = 'App版本'
verbose_name_plural = 'App版本'
ordering = ['-is_current', '-created_at']
constraints = [
models.UniqueConstraint(
fields=['is_current'],
condition=Q(is_current=True),
name='uniq_current_app_version',
),
]
def __str__(self):
return f'{self.major}.{self.minor}.{self.build}'
def save(self, *args, **kwargs):
if self.package_file:
self.download_url = self.package_file.url
if self.is_current:
AppVersion.objects.exclude(pk=self.pk).filter(is_current=True).update(is_current=False)
super().save(*args, **kwargs)
class UploadedFile(ModelBase):
"""
通用文件上传记录

View File

@@ -32,6 +32,7 @@ from api_v1.mdy_plate_order_staging_tiia_upload import (
build_default_tiia_rate_limiter,
upload_mdy_plate_order_staging_plate_images_to_tencent_tiia,
)
from api_v1.external_datetime import parse_external_china_datetime
from api_v1.external_product_image_backfill import run_external_product_image_backfill
from printing import models as printing_models
@@ -897,8 +898,8 @@ def _build_external_order_data(
'rolling_warn': str(first.get('BeiZhu') or '').strip() or None,
'curve': str(first.get('MeoA') or '').strip() or None,
'position': str(first.get('FidJ') or '').strip() or None,
'kd_riqi': _parse_external_datetime(first.get('KdRiQi')),
'outgoing_date': _parse_external_datetime(first.get('KdRiQi')),
'kd_riqi': parse_external_china_datetime(first.get('KdRiQi')),
'outgoing_date': parse_external_china_datetime(first.get('KdRiQi')),
'created_by': created_by_user,
'external_order_id': external_order_id,
'external_customer_id': external_customer_id or None,

View File

@@ -164,7 +164,14 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
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, '周边')
self.assertEqual(job.printing_order.kd_riqi.isoformat(), '2026-01-26T20:00:52+00:00')
self.assertEqual(
timezone.localtime(job.printing_order.kd_riqi).isoformat(),
'2026-01-26T20:00:52+08:00',
)
self.assertEqual(
timezone.localtime(job.printing_order.outgoing_date).isoformat(),
'2026-01-26T20:00:52+08:00',
)
@patch('api_v1.tasks._advance_external_printing_cursor')
@patch('api_v1.tasks._fetch_external_product_image')

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):