forked from erp-dev/erp
feat: shipment change && version modelize
This commit is contained in:
@@ -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']
|
||||
|
||||
23
api_v1/external_datetime.py
Normal file
23
api_v1/external_datetime.py
Normal 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)
|
||||
@@ -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)))
|
||||
|
||||
39
api_v1/migrations/0018_appversion.py
Normal file
39
api_v1/migrations/0018_appversion.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
通用文件上传记录
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user