diff --git a/api_v1/admin.py b/api_v1/admin.py index dec7e1a..d7517f4 100644 --- a/api_v1/admin.py +++ b/api_v1/admin.py @@ -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'] diff --git a/api_v1/external_datetime.py b/api_v1/external_datetime.py new file mode 100644 index 0000000..7491188 --- /dev/null +++ b/api_v1/external_datetime.py @@ -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) diff --git a/api_v1/management/commands/set_app_version.py b/api_v1/management/commands/set_app_version.py index d8cd970..cc385bc 100644 --- a/api_v1/management/commands/set_app_version.py +++ b/api_v1/management/commands/set_app_version.py @@ -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))) diff --git a/api_v1/migrations/0018_appversion.py b/api_v1/migrations/0018_appversion.py new file mode 100644 index 0000000..08c275f --- /dev/null +++ b/api_v1/migrations/0018_appversion.py @@ -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'), + ), + ] diff --git a/api_v1/models.py b/api_v1/models.py index 01e51a4..eff0ba7 100644 --- a/api_v1/models.py +++ b/api_v1/models.py @@ -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): """ 通用文件上传记录 diff --git a/api_v1/tasks.py b/api_v1/tasks.py index 4264edc..9d1eb18 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -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, diff --git a/api_v1/test_external_printing_records_sync.py b/api_v1/test_external_printing_records_sync.py index 7602e93..9c3b201 100644 --- a/api_v1/test_external_printing_records_sync.py +++ b/api_v1/test_external_printing_records_sync.py @@ -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') diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index a45a868..650adab 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -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', ] diff --git a/api_v1/views/printing/test_api.py b/api_v1/views/printing/test_api.py index 0bb08f2..1090191 100644 --- a/api_v1/views/printing/test_api.py +++ b/api_v1/views/printing/test_api.py @@ -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): diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index e9d2285..d468791 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -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"] diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index 7b1d9b6..c0f71a1 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -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) diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 74a737e..4a4fd39 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -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) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index 1fa93b1..b3d0a2c 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -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): diff --git a/api_v2/test_auth_login_api.py b/api_v2/test_auth_login_api.py index 29f902c..9a96b9e 100644 --- a/api_v2/test_auth_login_api.py +++ b/api_v2/test_auth_login_api.py @@ -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') diff --git a/api_v2/tests.py b/api_v2/tests.py index 85d82cc..aa619db 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -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']) diff --git a/docs/api_v1_shipment_stage_query_2026-07-09.md b/docs/api_v1_shipment_stage_query_2026-07-09.md new file mode 100644 index 0000000..584e8a5 --- /dev/null +++ b/docs/api_v1_shipment_stage_query_2026-07-09.md @@ -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` 都是过滤后的结果。 diff --git a/docs/printing_external_records_sync_2026-03-20.md b/docs/printing_external_records_sync_2026-03-20.md index 324a4b1..24b6960 100644 --- a/docs/printing_external_records_sync_2026-03-20.md +++ b/docs/printing_external_records_sync_2026-03-20.md @@ -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 diff --git a/flower-app_latest.tar.gz b/flower-app_latest.tar.gz deleted file mode 100644 index 426b3ca..0000000 Binary files a/flower-app_latest.tar.gz and /dev/null differ diff --git a/flower/app_version.py b/flower/app_version.py index 991b8cc..8e87605 100644 --- a/flower/app_version.py +++ b/flower/app_version.py @@ -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"], - ) - except (KeyError, TypeError, ValueError): - pass + 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, + ) 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): diff --git a/flower/test_app_version_api.py b/flower/test_app_version_api.py index 9c1a6cf..71e43a6 100644 --- a/flower/test_app_version_api.py +++ b/flower/test_app_version_api.py @@ -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"], "强制升级说明") diff --git a/flower/urls.py b/flower/urls.py index d00fda5..d5ac551 100644 --- a/flower/urls.py +++ b/flower/urls.py @@ -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'), diff --git a/printing/management/commands/backfill_printing_order_kd_riqi.py b/printing/management/commands/backfill_printing_order_kd_riqi.py new file mode 100644 index 0000000..ef655d2 --- /dev/null +++ b/printing/management/commands/backfill_printing_order_kd_riqi.py @@ -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))) diff --git a/printing/test_backfill_printing_order_kd_riqi_command.py b/printing/test_backfill_printing_order_kd_riqi_command.py new file mode 100644 index 0000000..9b55936 --- /dev/null +++ b/printing/test_backfill_printing_order_kd_riqi_command.py @@ -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'])