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(
|
||||
@@ -343,6 +347,39 @@ class PrintingOrderAPITestCase(TestCase):
|
||||
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):
|
||||
"""测试获取订单详情"""
|
||||
order = printing_models.PrintingOrder.objects.create(
|
||||
@@ -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):
|
||||
|
||||
@@ -11,6 +11,7 @@ from flower.error_code import AuthErrorCode
|
||||
class AuthLoginAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.url = '/api/auth/login/'
|
||||
self.refresh_url = '/api/auth/refresh/'
|
||||
self.client = APIClient()
|
||||
self.merchant = Merchant.objects.create(name='登录商户', type=MerchantTypeEnum.FACTORY)
|
||||
|
||||
@@ -45,6 +46,23 @@ class AuthLoginAPITestCase(TestCase):
|
||||
self.assertIn('access', response.data)
|
||||
self.assertIn('refresh', response.data)
|
||||
|
||||
def test_refresh_token_returns_new_access_token(self):
|
||||
self._create_user_with_employee(username='refresh_user', password='pass12345')
|
||||
login_response = self.client.post(
|
||||
self.url,
|
||||
{'username': 'refresh_user', 'password': 'pass12345'},
|
||||
format='json',
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
self.refresh_url,
|
||||
{'refresh': login_response.data['refresh']},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('access', response.data)
|
||||
|
||||
def test_login_requires_username_and_password(self):
|
||||
response = self.client.post(self.url, {}, format='json')
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ class PrintingOrderWithoutSalesOrderAPITest(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
by_id = {item['id']: item for item in response.data}
|
||||
self.assertEqual(by_id[self.unbound_order.id]['bianhao_kd'], '7.07')
|
||||
self.assertEqual(by_id[self.unbound_order.id]['kd_riqi'], '2026-07-06T21:48:58Z')
|
||||
self.assertEqual(by_id[self.unbound_order.id]['kd_riqi'], '2026-07-06T21:48:58+08:00')
|
||||
self.assertIsNone(by_id[self.legacy_unbound_order.id]['bianhao_kd'])
|
||||
self.assertIsNone(by_id[self.legacy_unbound_order.id]['kd_riqi'])
|
||||
|
||||
|
||||
113
docs/api_v1_shipment_stage_query_2026-07-09.md
Normal file
113
docs/api_v1_shipment_stage_query_2026-07-09.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Shipment 逻辑状态查询 API
|
||||
|
||||
## 接口
|
||||
|
||||
```http
|
||||
GET /api/v1/shipment/shipments/
|
||||
```
|
||||
|
||||
该接口为出货单列表接口,支持分页。本文只说明逻辑状态查询字段 `shipment_stage`。
|
||||
|
||||
## 查询参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `shipment_stage` | string | 否 | 出货单逻辑状态 |
|
||||
| `limit` | integer | 否 | 分页大小 |
|
||||
| `offset` | integer | 否 | 分页偏移 |
|
||||
|
||||
`shipment_stage` 不使用原有 `status` 字段,避免和出货单自身状态冲突。
|
||||
|
||||
## shipment_stage 枚举
|
||||
|
||||
| 值 | 展示文案 | 过滤规则 |
|
||||
| --- | --- | --- |
|
||||
| `missing_address` | 待补地址 | 出货单未取消、未绑定送货单、`address` 为空 |
|
||||
| `deliverable` | 可送货 | 出货单未取消、未绑定送货单、`address` 非空 |
|
||||
| `scheduled` | 已排车 | 出货单未取消、已绑定送货单,且送货单状态为 `待送货` 或 `送货中` |
|
||||
| `delivered` | 已送达 | 出货单未取消、已绑定送货单,且送货单状态为 `已送达` |
|
||||
| `cancelled` | 已取消 | 出货单自身状态为 `已取消` |
|
||||
|
||||
## 请求示例
|
||||
|
||||
查询待补地址:
|
||||
|
||||
```http
|
||||
GET /api/v1/shipment/shipments/?shipment_stage=missing_address&limit=20&offset=0
|
||||
```
|
||||
|
||||
查询可送货:
|
||||
|
||||
```http
|
||||
GET /api/v1/shipment/shipments/?shipment_stage=deliverable&limit=20&offset=0
|
||||
```
|
||||
|
||||
查询已排车:
|
||||
|
||||
```http
|
||||
GET /api/v1/shipment/shipments/?shipment_stage=scheduled&limit=20&offset=0
|
||||
```
|
||||
|
||||
查询已送达:
|
||||
|
||||
```http
|
||||
GET /api/v1/shipment/shipments/?shipment_stage=delivered&limit=20&offset=0
|
||||
```
|
||||
|
||||
查询已取消:
|
||||
|
||||
```http
|
||||
GET /api/v1/shipment/shipments/?shipment_stage=cancelled&limit=20&offset=0
|
||||
```
|
||||
|
||||
## 返回字段
|
||||
|
||||
列表结果中每条出货单新增:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `shipment_stage` | string/null | 逻辑状态枚举值 |
|
||||
| `shipment_stage_display` | string/null | 逻辑状态展示文案 |
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 123,
|
||||
"status": 5,
|
||||
"status_display": "已审核",
|
||||
"shipment_stage": "scheduled",
|
||||
"shipment_stage_display": "已排车",
|
||||
"delivery_id": 88,
|
||||
"address": "绍兴市测试路 8 号"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 错误响应
|
||||
|
||||
如果 `shipment_stage` 不是支持的枚举值,返回 `400`。
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
GET /api/v1/shipment/shipments/?shipment_stage=unknown
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"shipment_stage": "shipment_stage 必须是 cancelled, deliverable, delivered, missing_address, scheduled 之一"
|
||||
}
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
- `status` 仍表示出货单自身状态,不表示逻辑状态。
|
||||
- `shipment_stage` 是根据出货单地址、送货单绑定关系、送货单状态动态计算出来的逻辑状态。
|
||||
- 过滤在数据库查询阶段完成,因此分页 `count` 和 `results` 都是过滤后的结果。
|
||||
@@ -47,7 +47,7 @@
|
||||
| `MeoA` | 曲线 | `PrintingOrder.curve` | 原样保存 |
|
||||
| `FidJ` | 电脑位置 | `PrintingOrder.position` | 取分组首条 record 的原始值 |
|
||||
| `BeiZhu` | 滚筒注意事项 | `PrintingOrder.rolling_warn` | 原样保存 |
|
||||
| `KdRiQi` | 下单日期 | `PrintingOrder.outgoing_date` | 按 ISO 时间解析并保存 |
|
||||
| `KdRiQi` | 外部开单日期时间 | `PrintingOrder.outgoing_date` / `PrintingOrder.kd_riqi` | 按中国时间解析并保存;即使外部字符串带 `Z`,也按本地业务时间解释;`kd_riqi` 用于 API 展示、筛选和排序 |
|
||||
| 分组首条原始数据 | 外部来源快照 | `PrintingOrder.external_raw` | 保存 order 级别的原始数据,便于追溯 |
|
||||
|
||||
补充说明:
|
||||
@@ -56,6 +56,80 @@
|
||||
- 外部 `area` 直接写入 `PrintingOrder.area`;若外部未返回或为空,则统一写入空字符串
|
||||
- `BianHaoKD`、`RiQi` 等当前未单独属性化的字段,保留在 `external_raw` 中
|
||||
- `BianHaoKD` 当前样例值类似 `"1.27"`,不按日期强解析
|
||||
- 外部 `KdRiQi` 样例可能形如 `2026-07-08T17:22:22Z`,但业务语义为中国时间 `2026-07-08 17:22:22`,不能按 UTC 解释,否则 API 展示会偏移 8 小时
|
||||
|
||||
## 历史 `kd_riqi` 回填命令
|
||||
|
||||
新增字段 `PrintingOrder.kd_riqi` 后,新同步数据会自动写入该字段。历史数据的 `KdRiQi` 已保存在 `external_raw.first_record.KdRiQi`,可用以下 management command 批量回填。
|
||||
|
||||
命令文件:
|
||||
|
||||
- `printing/management/commands/backfill_printing_order_kd_riqi.py`
|
||||
|
||||
默认处理范围:
|
||||
|
||||
- 只处理 `PrintingOrder.kd_riqi IS NULL` 的订单
|
||||
- 从 `external_raw.first_record.KdRiQi` 提取并解析时间
|
||||
- 解析成功则批量写入 `kd_riqi`
|
||||
- 缺失或解析失败的数据跳过并计数
|
||||
- 不覆盖已有 `kd_riqi`
|
||||
- 使用 `bulk_update(['kd_riqi'])`,不会更新 `updated_at`
|
||||
|
||||
历史错误值修正模式:
|
||||
|
||||
- 传 `--overwrite` 后,不再限定 `kd_riqi IS NULL`
|
||||
- 所有存在 `external_raw` 且能读取 `external_raw.first_record.KdRiQi` 的订单都会重新解析并覆盖 `kd_riqi`
|
||||
- 传 `--update-outgoing-date` 后,会同时覆盖 `outgoing_date`
|
||||
- 该模式用于修正早期把外部 `KdRiQi` 误按 UTC 解析导致的 8 小时偏移
|
||||
|
||||
推荐先 dry-run:
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi --dry-run
|
||||
```
|
||||
|
||||
确认统计后批量执行:
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi
|
||||
```
|
||||
|
||||
常用参数:
|
||||
|
||||
- `--dry-run`:只统计,不写入
|
||||
- `--batch-size 1000`:批量读取和批量写入大小,默认 `1000`
|
||||
- `--limit 5000`:最多处理多少条候选记录
|
||||
- `--merchant-id 1`:限定商户
|
||||
- `--external-order-id KD20453713`:限定外部订单编号,适合单条验证
|
||||
- `--overwrite`:覆盖已有 `kd_riqi`,用于修正历史按 UTC 解析导致的偏移数据
|
||||
- `--update-outgoing-date`:同时用 `KdRiQi` 修正 `outgoing_date`
|
||||
|
||||
输出为 JSON,典型字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"dry_run": true,
|
||||
"matched": 18000,
|
||||
"updated": 0,
|
||||
"would_update": 17800,
|
||||
"skipped_missing": 150,
|
||||
"skipped_invalid": 50,
|
||||
"overwrite": false,
|
||||
"update_outgoing_date": false
|
||||
}
|
||||
```
|
||||
|
||||
历史偏移数据修正建议先 dry-run:
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi --overwrite --update-outgoing-date --dry-run
|
||||
```
|
||||
|
||||
确认后执行:
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi --overwrite --update-outgoing-date
|
||||
```
|
||||
|
||||
### 二、单条 record -> PrintingJob
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,10 +1,11 @@
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from api_v1.models import AppVersion
|
||||
|
||||
|
||||
APP_VERSION_CACHE_KEY = "app_version:latest"
|
||||
|
||||
@@ -25,6 +26,7 @@ def build_app_version_payload(
|
||||
download_url: str,
|
||||
force=False,
|
||||
publish_date=None,
|
||||
message: str = "",
|
||||
) -> dict:
|
||||
return {
|
||||
"latest_version": {
|
||||
@@ -35,24 +37,25 @@ def build_app_version_payload(
|
||||
"download_url": str(download_url or "").strip(),
|
||||
"force": bool(force),
|
||||
"publish_date": _coerce_publish_date(publish_date),
|
||||
"message": str(message or ""),
|
||||
}
|
||||
|
||||
|
||||
def get_app_version_payload() -> dict:
|
||||
cached_payload = cache.get(APP_VERSION_CACHE_KEY)
|
||||
if isinstance(cached_payload, dict):
|
||||
try:
|
||||
version = cached_payload["latest_version"]
|
||||
return build_app_version_payload(
|
||||
major=version["major"],
|
||||
minor=version["minor"],
|
||||
build=version["build"],
|
||||
download_url=cached_payload.get("download_url", ""),
|
||||
force=cached_payload["force"],
|
||||
publish_date=cached_payload["publish_date"],
|
||||
app_version = (
|
||||
AppVersion.objects.filter(is_current=True).order_by('-created_at').first()
|
||||
or AppVersion.objects.order_by('-created_at').first()
|
||||
)
|
||||
if app_version:
|
||||
return build_app_version_payload(
|
||||
major=app_version.major,
|
||||
minor=app_version.minor,
|
||||
build=app_version.build,
|
||||
download_url=app_version.download_url,
|
||||
force=app_version.force,
|
||||
publish_date=app_version.publish_date,
|
||||
message=app_version.message,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return build_app_version_payload(
|
||||
major=getattr(settings, "APP_LATEST_VERSION_MAJOR", 0),
|
||||
@@ -61,6 +64,7 @@ def get_app_version_payload() -> dict:
|
||||
download_url=getattr(settings, "APP_DOWNLOAD_URL", ""),
|
||||
force=getattr(settings, "APP_FORCE_UPDATE", False),
|
||||
publish_date=getattr(settings, "APP_PUBLISH_DATE", None),
|
||||
message=getattr(settings, "APP_VERSION_MESSAGE", ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -72,17 +76,27 @@ def set_cached_app_version_payload(
|
||||
download_url: str,
|
||||
force=False,
|
||||
publish_date=None,
|
||||
message: str = "",
|
||||
) -> dict:
|
||||
payload = build_app_version_payload(
|
||||
app_version = AppVersion.objects.create(
|
||||
major=major,
|
||||
minor=minor,
|
||||
build=build,
|
||||
download_url=download_url,
|
||||
force=force,
|
||||
publish_date=publish_date,
|
||||
publish_date=publish_date or timezone.localdate(),
|
||||
message=message,
|
||||
is_current=True,
|
||||
)
|
||||
return build_app_version_payload(
|
||||
major=app_version.major,
|
||||
minor=app_version.minor,
|
||||
build=app_version.build,
|
||||
download_url=app_version.download_url,
|
||||
force=app_version.force,
|
||||
publish_date=app_version.publish_date,
|
||||
message=app_version.message,
|
||||
)
|
||||
cache.set(APP_VERSION_CACHE_KEY, payload, timeout=None)
|
||||
return payload
|
||||
|
||||
|
||||
class AppVersionView(APIView):
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from api_v1.models import AppVersion
|
||||
from flower.app_version import APP_VERSION_CACHE_KEY
|
||||
|
||||
|
||||
@@ -40,9 +41,84 @@ class AppVersionAPITest(TestCase):
|
||||
"download_url": "https://example.com/app.apk",
|
||||
"force": True,
|
||||
"publish_date": "2026-07-08",
|
||||
"message": "",
|
||||
},
|
||||
)
|
||||
|
||||
def test_app_version_api_returns_current_model_version(self):
|
||||
AppVersion.objects.create(
|
||||
major=1,
|
||||
minor=0,
|
||||
build=100,
|
||||
download_url="https://example.com/old.apk",
|
||||
publish_date="2026-07-01",
|
||||
message="旧版本",
|
||||
)
|
||||
AppVersion.objects.create(
|
||||
major=2,
|
||||
minor=0,
|
||||
build=200,
|
||||
download_url="https://example.com/current.apk",
|
||||
force=True,
|
||||
publish_date="2026-07-08",
|
||||
message="请更新到最新版本",
|
||||
is_current=True,
|
||||
)
|
||||
|
||||
response = self.client.get("/api/app-version/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["latest_version"], {"major": 2, "minor": 0, "build": 200})
|
||||
self.assertEqual(response.data["download_url"], "https://example.com/current.apk")
|
||||
self.assertEqual(response.data["force"], True)
|
||||
self.assertEqual(response.data["publish_date"], "2026-07-08")
|
||||
self.assertEqual(response.data["message"], "请更新到最新版本")
|
||||
|
||||
def test_app_version_api_returns_newest_model_version_when_no_current(self):
|
||||
AppVersion.objects.create(
|
||||
major=1,
|
||||
minor=0,
|
||||
build=100,
|
||||
download_url="https://example.com/old.apk",
|
||||
publish_date="2026-07-01",
|
||||
)
|
||||
AppVersion.objects.create(
|
||||
major=1,
|
||||
minor=1,
|
||||
build=110,
|
||||
download_url="https://example.com/new.apk",
|
||||
publish_date="2026-07-02",
|
||||
message="最新创建版本",
|
||||
)
|
||||
|
||||
response = self.client.get("/api/app-version/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["latest_version"], {"major": 1, "minor": 1, "build": 110})
|
||||
self.assertEqual(response.data["download_url"], "https://example.com/new.apk")
|
||||
self.assertEqual(response.data["message"], "最新创建版本")
|
||||
|
||||
def test_app_version_current_flag_is_mutually_exclusive(self):
|
||||
first = AppVersion.objects.create(
|
||||
major=1,
|
||||
minor=0,
|
||||
build=100,
|
||||
download_url="https://example.com/first.apk",
|
||||
is_current=True,
|
||||
)
|
||||
second = AppVersion.objects.create(
|
||||
major=2,
|
||||
minor=0,
|
||||
build=200,
|
||||
download_url="https://example.com/second.apk",
|
||||
is_current=True,
|
||||
)
|
||||
|
||||
first.refresh_from_db()
|
||||
second.refresh_from_db()
|
||||
self.assertFalse(first.is_current)
|
||||
self.assertTrue(second.is_current)
|
||||
|
||||
def test_set_app_version_command_updates_cached_api_payload_with_defaults(self):
|
||||
output = StringIO()
|
||||
call_command(
|
||||
@@ -65,6 +141,7 @@ class AppVersionAPITest(TestCase):
|
||||
self.assertEqual(response.data["download_url"], "https://example.com/latest.apk")
|
||||
self.assertEqual(response.data["force"], False)
|
||||
self.assertEqual(response.data["publish_date"], timezone.localdate().isoformat())
|
||||
self.assertEqual(response.data["message"], "")
|
||||
self.assertIn("'build': 1001", output.getvalue())
|
||||
|
||||
def test_set_app_version_command_accepts_force_and_publish_date(self):
|
||||
@@ -82,6 +159,8 @@ class AppVersionAPITest(TestCase):
|
||||
"--force",
|
||||
"--publish-date",
|
||||
"2026-07-01",
|
||||
"--message",
|
||||
"强制升级说明",
|
||||
stdout=output,
|
||||
)
|
||||
|
||||
@@ -92,3 +171,4 @@ class AppVersionAPITest(TestCase):
|
||||
self.assertEqual(response.data["download_url"], "https://example.com/force.apk")
|
||||
self.assertEqual(response.data["force"], True)
|
||||
self.assertEqual(response.data["publish_date"], "2026-07-01")
|
||||
self.assertEqual(response.data["message"], "强制升级说明")
|
||||
|
||||
@@ -27,7 +27,7 @@ from rest_framework_simplejwt.settings import api_settings
|
||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenObtainPairView,
|
||||
# TokenRefreshView,
|
||||
TokenRefreshView,
|
||||
)
|
||||
|
||||
from basic_info.models import EmployeeStatusEnum
|
||||
@@ -143,7 +143,7 @@ class CustomTokenObtainPairView(TokenObtainPairView):
|
||||
urlpatterns = [
|
||||
# JWT 登录
|
||||
path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
# path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('api/error-codes/', ErrorCodeListView.as_view(), name='error_code_list'),
|
||||
path('api/app-version/', AppVersionView.as_view(), name='app_version'),
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from api_v1.external_datetime import parse_external_china_datetime
|
||||
from printing.models import PrintingOrder
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '从 PrintingOrder.external_raw.first_record.KdRiQi 批量回填或修正 kd_riqi'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--dry-run', action='store_true', help='只统计不写入')
|
||||
parser.add_argument('--batch-size', type=int, default=1000, help='批量处理大小')
|
||||
parser.add_argument('--limit', type=int, default=None, help='最多处理多少条候选记录')
|
||||
parser.add_argument('--merchant-id', type=int, default=None, help='限定商户ID')
|
||||
parser.add_argument('--external-order-id', default=None, help='限定外部订单编号')
|
||||
parser.add_argument('--overwrite', action='store_true', help='覆盖已有 kd_riqi,用于修正历史错误值')
|
||||
parser.add_argument('--update-outgoing-date', action='store_true', help='同时用 KdRiQi 修正 outgoing_date')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = bool(options['dry_run'])
|
||||
batch_size = max(1, int(options['batch_size'] or 1000))
|
||||
limit = options.get('limit')
|
||||
overwrite = bool(options['overwrite'])
|
||||
update_outgoing_date = bool(options['update_outgoing_date'])
|
||||
|
||||
queryset = PrintingOrder.objects.exclude(external_raw={})
|
||||
if not overwrite:
|
||||
queryset = queryset.filter(kd_riqi__isnull=True)
|
||||
merchant_id = options.get('merchant_id')
|
||||
if merchant_id:
|
||||
queryset = queryset.filter(merchant_id=merchant_id)
|
||||
|
||||
external_order_id = (options.get('external_order_id') or '').strip()
|
||||
if external_order_id:
|
||||
queryset = queryset.filter(external_order_id=external_order_id)
|
||||
|
||||
queryset = queryset.order_by('id').only('id', 'external_raw', 'kd_riqi', 'outgoing_date')
|
||||
if limit is not None:
|
||||
queryset = queryset[: max(0, int(limit))]
|
||||
|
||||
matched = 0
|
||||
updated = 0
|
||||
skipped_missing = 0
|
||||
skipped_invalid = 0
|
||||
pending_updates = []
|
||||
update_fields = ['kd_riqi']
|
||||
if update_outgoing_date:
|
||||
update_fields.append('outgoing_date')
|
||||
|
||||
for order in queryset.iterator(chunk_size=batch_size):
|
||||
matched += 1
|
||||
try:
|
||||
first_record = (order.external_raw or {}).get('first_record') or {}
|
||||
raw_value = first_record.get('KdRiQi')
|
||||
except AttributeError:
|
||||
raw_value = None
|
||||
|
||||
if not raw_value:
|
||||
skipped_missing += 1
|
||||
continue
|
||||
|
||||
parsed = parse_external_china_datetime(raw_value)
|
||||
if parsed is None:
|
||||
skipped_invalid += 1
|
||||
continue
|
||||
|
||||
order.kd_riqi = parsed
|
||||
if update_outgoing_date:
|
||||
order.outgoing_date = parsed
|
||||
updated += 1
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
pending_updates.append(order)
|
||||
if len(pending_updates) >= batch_size:
|
||||
PrintingOrder.objects.bulk_update(pending_updates, update_fields)
|
||||
pending_updates = []
|
||||
|
||||
if pending_updates:
|
||||
PrintingOrder.objects.bulk_update(pending_updates, update_fields)
|
||||
|
||||
payload = {
|
||||
'dry_run': dry_run,
|
||||
'matched': matched,
|
||||
'updated': 0 if dry_run else updated,
|
||||
'would_update': updated if dry_run else 0,
|
||||
'skipped_missing': skipped_missing,
|
||||
'skipped_invalid': skipped_invalid,
|
||||
'overwrite': overwrite,
|
||||
'update_outgoing_date': update_outgoing_date,
|
||||
}
|
||||
self.stdout.write(self.style.SUCCESS(json.dumps(payload, ensure_ascii=False)))
|
||||
125
printing/test_backfill_printing_order_kd_riqi_command.py
Normal file
125
printing/test_backfill_printing_order_kd_riqi_command.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import json
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from printing import models as printing_models
|
||||
|
||||
|
||||
class BackfillPrintingOrderKdRiQiCommandTest(TestCase):
|
||||
def setUp(self):
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='测试印染厂',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
)
|
||||
|
||||
def _create_order(self, *, external_order_id, external_raw, kd_riqi=None, merchant=None):
|
||||
return printing_models.PrintingOrder.objects.create(
|
||||
merchant=merchant or self.merchant,
|
||||
customer=self.customer,
|
||||
fabric='测试面料',
|
||||
width='160cm',
|
||||
external_order_id=external_order_id,
|
||||
external_raw=external_raw,
|
||||
kd_riqi=kd_riqi,
|
||||
)
|
||||
|
||||
def test_dry_run_reports_without_writing(self):
|
||||
order = self._create_order(
|
||||
external_order_id='KD001',
|
||||
external_raw={'first_record': {'KdRiQi': '2026-01-26T20:00:52Z'}},
|
||||
)
|
||||
output = StringIO()
|
||||
|
||||
call_command('backfill_printing_order_kd_riqi', '--dry-run', stdout=output)
|
||||
|
||||
order.refresh_from_db()
|
||||
payload = json.loads(output.getvalue())
|
||||
self.assertIsNone(order.kd_riqi)
|
||||
self.assertEqual(payload['would_update'], 1)
|
||||
self.assertEqual(payload['updated'], 0)
|
||||
|
||||
def test_backfills_missing_kd_riqi_from_external_raw(self):
|
||||
order = self._create_order(
|
||||
external_order_id='KD001',
|
||||
external_raw={'first_record': {'KdRiQi': '2026-01-26T20:00:52Z'}},
|
||||
)
|
||||
output = StringIO()
|
||||
|
||||
call_command('backfill_printing_order_kd_riqi', stdout=output)
|
||||
|
||||
order.refresh_from_db()
|
||||
payload = json.loads(output.getvalue())
|
||||
self.assertEqual(timezone.localtime(order.kd_riqi).isoformat(), '2026-01-26T20:00:52+08:00')
|
||||
self.assertEqual(payload['updated'], 1)
|
||||
|
||||
def test_skips_missing_and_invalid_values(self):
|
||||
missing = self._create_order(
|
||||
external_order_id='KD001',
|
||||
external_raw={'first_record': {}},
|
||||
)
|
||||
invalid = self._create_order(
|
||||
external_order_id='KD002',
|
||||
external_raw={'first_record': {'KdRiQi': 'invalid'}},
|
||||
)
|
||||
output = StringIO()
|
||||
|
||||
call_command('backfill_printing_order_kd_riqi', stdout=output)
|
||||
|
||||
missing.refresh_from_db()
|
||||
invalid.refresh_from_db()
|
||||
payload = json.loads(output.getvalue())
|
||||
self.assertIsNone(missing.kd_riqi)
|
||||
self.assertIsNone(invalid.kd_riqi)
|
||||
self.assertEqual(payload['skipped_missing'], 1)
|
||||
self.assertEqual(payload['skipped_invalid'], 1)
|
||||
|
||||
def test_filters_by_external_order_id(self):
|
||||
target = self._create_order(
|
||||
external_order_id='KD001',
|
||||
external_raw={'first_record': {'KdRiQi': '2026-01-26T20:00:52Z'}},
|
||||
)
|
||||
other = self._create_order(
|
||||
external_order_id='KD002',
|
||||
external_raw={'first_record': {'KdRiQi': '2026-01-27T20:00:52Z'}},
|
||||
)
|
||||
|
||||
call_command('backfill_printing_order_kd_riqi', '--external-order-id', 'KD001', stdout=StringIO())
|
||||
|
||||
target.refresh_from_db()
|
||||
other.refresh_from_db()
|
||||
self.assertIsNotNone(target.kd_riqi)
|
||||
self.assertIsNone(other.kd_riqi)
|
||||
|
||||
def test_overwrite_can_update_kd_riqi_and_outgoing_date(self):
|
||||
order = self._create_order(
|
||||
external_order_id='KD001',
|
||||
external_raw={'first_record': {'KdRiQi': '2026-01-26T20:00:52Z'}},
|
||||
kd_riqi=datetime(2026, 1, 26, 20, 0, 52, tzinfo=dt_timezone.utc),
|
||||
)
|
||||
order.outgoing_date = datetime(2026, 1, 26, 20, 0, 52, tzinfo=dt_timezone.utc)
|
||||
order.save(update_fields=['outgoing_date'])
|
||||
output = StringIO()
|
||||
|
||||
call_command(
|
||||
'backfill_printing_order_kd_riqi',
|
||||
'--overwrite',
|
||||
'--update-outgoing-date',
|
||||
stdout=output,
|
||||
)
|
||||
|
||||
order.refresh_from_db()
|
||||
payload = json.loads(output.getvalue())
|
||||
self.assertEqual(timezone.localtime(order.kd_riqi).isoformat(), '2026-01-26T20:00:52+08:00')
|
||||
self.assertEqual(timezone.localtime(order.outgoing_date).isoformat(), '2026-01-26T20:00:52+08:00')
|
||||
self.assertEqual(payload['updated'], 1)
|
||||
self.assertTrue(payload['overwrite'])
|
||||
self.assertTrue(payload['update_outgoing_date'])
|
||||
Reference in New Issue
Block a user