1
0
forked from erp-dev/erp

feat: shipment change && version modelize

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

View File

@@ -2,6 +2,7 @@ from django.contrib import admin
from django.contrib.admin import action from django.contrib.admin import action
from api_v1.models import ( from api_v1.models import (
UploadedFile, UploadedFile,
AppVersion,
DataSync, DataSync,
MDYPlateOrderStaging, MDYPlateOrderStaging,
MDYPlateOrderStagingTiiaUploadFailure, MDYPlateOrderStagingTiiaUploadFailure,
@@ -32,6 +33,41 @@ class UploadedFileAdmin(admin.ModelAdmin):
backup_database.delay() 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) @admin.register(DataSync)
class DataSyncAdmin(admin.ModelAdmin): class DataSyncAdmin(admin.ModelAdmin):
list_display = ['id', 'table_name', 'page_index', 'page_size', 'total_count', 'synced_rows', 'last_ctime', 'last_rowid', 'note'] list_display = ['id', 'table_name', 'page_index', 'page_size', 'total_count', 'synced_rows', 'last_ctime', 'last_rowid', 'note']

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,10 @@ API v1 通用模型
import os import os
import uuid import uuid
from django.db import models from django.db import models
from django.db.models import Q
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
from django.utils import timezone
from flower.common import ModelBase from flower.common import ModelBase
from basic_info import models as basic_info_models 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}" 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): class UploadedFile(ModelBase):
""" """
通用文件上传记录 通用文件上传记录

View File

@@ -32,6 +32,7 @@ from api_v1.mdy_plate_order_staging_tiia_upload import (
build_default_tiia_rate_limiter, build_default_tiia_rate_limiter,
upload_mdy_plate_order_staging_plate_images_to_tencent_tiia, 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 api_v1.external_product_image_backfill import run_external_product_image_backfill
from printing import models as printing_models 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, 'rolling_warn': str(first.get('BeiZhu') or '').strip() or None,
'curve': str(first.get('MeoA') or '').strip() or None, 'curve': str(first.get('MeoA') or '').strip() or None,
'position': str(first.get('FidJ') or '').strip() or None, 'position': str(first.get('FidJ') or '').strip() or None,
'kd_riqi': _parse_external_datetime(first.get('KdRiQi')), 'kd_riqi': parse_external_china_datetime(first.get('KdRiQi')),
'outgoing_date': _parse_external_datetime(first.get('KdRiQi')), 'outgoing_date': parse_external_china_datetime(first.get('KdRiQi')),
'created_by': created_by_user, 'created_by': created_by_user,
'external_order_id': external_order_id, 'external_order_id': external_order_id,
'external_customer_id': external_customer_id or None, 'external_customer_id': external_customer_id or None,

View File

@@ -164,7 +164,14 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
self.assertEqual(job.printing_order.position, r'\\fw\\2026年-LWQ15\\2026\\H鸿烨\\Tj1712#') self.assertEqual(job.printing_order.position, r'\\fw\\2026年-LWQ15\\2026\\H鸿烨\\Tj1712#')
self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定') self.assertEqual(job.printing_order.fabric, '120克本白四面弹单定')
self.assertEqual(job.printing_order.area, '周边') 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._advance_external_printing_cursor')
@patch('api_v1.tasks._fetch_external_product_image') @patch('api_v1.tasks._fetch_external_product_image')

View File

@@ -7,6 +7,7 @@ from rest_framework import serializers
from rest_framework.fields import empty from rest_framework.fields import empty
from api_v1.models import UploadedFile 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.utils.media import build_public_media_url
from api_v1.views.shipment.serializers import SalesItemSerializer from api_v1.views.shipment.serializers import SalesItemSerializer
from printing import models from printing import models
@@ -54,7 +55,11 @@ def resolve_printing_order_kd_riqi(obj):
value = getattr(obj, 'kd_riqi', None) value = getattr(obj, 'kd_riqi', None)
if value is not None: if value is not None:
return serializers.DateTimeField().to_representation(value) 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): def _serialize_plate_images(raw_value, request):
@@ -354,13 +359,24 @@ class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
], ],
help_text="出货日期时间(可仅传 YYYY-MM-DD将自动视为 00:00:00", 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: class Meta:
model = models.PrintingOrder model = models.PrintingOrder
fields = [ fields = [
'id', 'customer', 'fabric', 'width', 'is_urgent', 'area', 'address', 'id', 'customer', 'fabric', 'width', 'is_urgent', 'area', 'address',
'fabric_source', 'is_fabric_received', 'craft', 'description', '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', 'printing_warn', 'rolling_warn', 'production_warn', 'is_invalid',
'process', 'external_order_id', 'process', 'external_order_id',
] ]

View File

@@ -94,6 +94,7 @@ class PrintingOrderAPITestCase(TestCase):
'craft': '活性印花', 'craft': '活性印花',
'description': '测试订单描述', 'description': '测试订单描述',
'outgoing_date': '2025-11-20', 'outgoing_date': '2025-11-20',
'kd_riqi': '2025-11-19 13:14:15',
'printing_warn': '注意颜色', 'printing_warn': '注意颜色',
'rolling_warn': '注意温度', 'rolling_warn': '注意温度',
'production_warn': '质量检查' 'production_warn': '质量检查'
@@ -120,6 +121,9 @@ class PrintingOrderAPITestCase(TestCase):
self.assertEqual(str(local_dt.date()), '2025-11-20') self.assertEqual(str(local_dt.date()), '2025-11-20')
self.assertEqual(local_dt.hour, 0) self.assertEqual(local_dt.hour, 0)
self.assertEqual(local_dt.minute, 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): 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_customer_name'], '李泽柔')
self.assertEqual(item['external_employee_name'], '丽容') self.assertEqual(item['external_employee_name'], '丽容')
self.assertEqual(item['bianhao_kd'], '7.07') 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}/') detail = self.client.get(f'/api/v1/printing-orders/{order1.id}/')
self.assertEqual(detail.status_code, status.HTTP_200_OK) self.assertEqual(detail.status_code, status.HTTP_200_OK)
self.assertEqual(detail.data['external_order_id'], 'KD20410611') self.assertEqual(detail.data['external_order_id'], 'KD20410611')
self.assertEqual(detail.data['external_customer_id'], 'KH00991') self.assertEqual(detail.data['external_customer_id'], 'KH00991')
self.assertEqual(detail.data['bianhao_kd'], '7.07') 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): def test_bianhao_kd_returns_null_without_external_order_id(self):
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
@@ -343,6 +347,39 @@ class PrintingOrderAPITestCase(TestCase):
self.assertEqual(result_ids, [older.id, newer.id]) self.assertEqual(result_ids, [older.id, newer.id])
self.assertEqual(response.data['results'][0]['kd_riqi'], '2026-07-01T18:00:00+08:00') 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): def test_retrieve_printing_order(self):
"""测试获取订单详情""" """测试获取订单详情"""
order = printing_models.PrintingOrder.objects.create( order = printing_models.PrintingOrder.objects.create(
@@ -418,7 +455,8 @@ class PrintingOrderAPITestCase(TestCase):
patch_data = { patch_data = {
'is_urgent': True, 'is_urgent': True,
'craft': '新工艺' 'craft': '新工艺',
'kd_riqi': '2025-11-21 09:30:00',
} }
response = self.client.patch( response = self.client.patch(
@@ -431,6 +469,7 @@ class PrintingOrderAPITestCase(TestCase):
order.refresh_from_db() order.refresh_from_db()
self.assertEqual(order.is_urgent, True) self.assertEqual(order.is_urgent, True)
self.assertEqual(order.craft, '新工艺') self.assertEqual(order.craft, '新工艺')
self.assertEqual(timezone.localtime(order.kd_riqi).isoformat(), '2025-11-21T09:30:00+08:00')
self.assertEqual(order.fabric, '原布料') # 未修改字段保持不变 self.assertEqual(order.fabric, '原布料') # 未修改字段保持不变
def test_delete_printing_order_forbidden(self): def test_delete_printing_order_forbidden(self):

View File

@@ -10,7 +10,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.permissions import DjangoModelPermissions from rest_framework.permissions import DjangoModelPermissions
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from django.core.exceptions import ValidationError 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.db.models.functions import Cast, Coalesce
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.views.decorators.http import condition from django.views.decorators.http import condition
@@ -38,6 +38,23 @@ from .serializers import (
from .mixins import CustomerVisibilityFilterMixin 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): class IsPrintingFactory(BasePermission):
"""自定义权限类,允许印染工厂用户访问""" """自定义权限类,允许印染工厂用户访问"""
@@ -276,7 +293,7 @@ class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter, PrintingOrderOrderingFilter,
] ]
filterset_class = PrintingOrderFilterSet filterset_class = PrintingOrderFilterSet
search_fields = ["customer__name", "fabric", "area", "craft", "description"] search_fields = ["customer__name", "fabric", "area", "craft", "description"]

View File

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

View File

@@ -2430,6 +2430,79 @@ class ShipmentQueryAPITestCase(TestCase):
self.assertEqual(data["results"][0]["id"], self.shipment1.id) self.assertEqual(data["results"][0]["id"], self.shipment1.id)
self.assertEqual(data["results"][0]["delivery_id"], delivery.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): def test_retrieve_shipment_success(self):
resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/") resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)

View File

@@ -3,6 +3,7 @@ Shipment API ViewSet
""" """
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.permissions import IsAuthenticated 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 django.utils.dateparse import parse_date, parse_datetime
from flower.viewsets import LimitedLimitOffsetPagination from flower.viewsets import LimitedLimitOffsetPagination
from shipment.models import Shipment, ShipmentDelivery from shipment.models import Shipment, ShipmentDelivery, ShipmentDeliveryStatus, ShipmentStatus
from .serializers import ( from .serializers import (
SalesItemDetailSerializer, SalesItemDetailSerializer,
@@ -30,9 +31,23 @@ from .serializers import (
ShipmentCreateExternalSerializer, ShipmentCreateExternalSerializer,
ShipmentSalesItemCustomerSerializer, ShipmentSalesItemCustomerSerializer,
ShipmentUpdateSerializer, 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): def _build_sales_item_serializer_context(items):
customer_ids = {item.customer_id for item in items if item.customer_id} 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} 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=已审核) - status: 状态1=草稿, 2=已发布, 3=已取消, 4=已驳回, 5=已审核)
- delivery_id: 送货单ID仅当传入具体ID时过滤null/空值不触发过滤) - delivery_id: 送货单ID仅当传入具体ID时过滤null/空值不触发过滤)
- delivery_isnull: 是否仅查询未绑定/已绑定送货单的出货单true/false - delivery_isnull: 是否仅查询未绑定/已绑定送货单的出货单true/false
- shipment_stage: 逻辑状态missing_address/deliverable/scheduled/delivered/cancelled
- external_id: 外部订单号(精确匹配) - external_id: 外部订单号(精确匹配)
- shipment_date_from: 出货日期起始YYYY-MM-DD - shipment_date_from: 出货日期起始YYYY-MM-DD
- shipment_date_to: 出货日期结束YYYY-MM-DD包含整天 - shipment_date_to: 出货日期结束YYYY-MM-DD包含整天
""" """
qs = ( qs = (
Shipment.objects.all() Shipment.objects.all()
.select_related("merchant", "customer", "created_by", "cancelled_by") .select_related("merchant", "customer", "created_by", "cancelled_by", "delivery")
.prefetch_related( .prefetch_related(
"items", "items",
"external_finished_products", "external_finished_products",
@@ -190,6 +206,35 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView):
elif normalized in {"0", "false", "no"}: elif normalized in {"0", "false", "no"}:
qs = qs.exclude(address="") 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") return qs.order_by("-created_at", "-id")
def get(self, request): def get(self, request):

View File

@@ -11,6 +11,7 @@ from flower.error_code import AuthErrorCode
class AuthLoginAPITestCase(TestCase): class AuthLoginAPITestCase(TestCase):
def setUp(self): def setUp(self):
self.url = '/api/auth/login/' self.url = '/api/auth/login/'
self.refresh_url = '/api/auth/refresh/'
self.client = APIClient() self.client = APIClient()
self.merchant = Merchant.objects.create(name='登录商户', type=MerchantTypeEnum.FACTORY) self.merchant = Merchant.objects.create(name='登录商户', type=MerchantTypeEnum.FACTORY)
@@ -45,6 +46,23 @@ class AuthLoginAPITestCase(TestCase):
self.assertIn('access', response.data) self.assertIn('access', response.data)
self.assertIn('refresh', 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): def test_login_requires_username_and_password(self):
response = self.client.post(self.url, {}, format='json') response = self.client.post(self.url, {}, format='json')

View File

@@ -417,7 +417,7 @@ class PrintingOrderWithoutSalesOrderAPITest(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
by_id = {item['id']: item for item in response.data} 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]['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]['bianhao_kd'])
self.assertIsNone(by_id[self.legacy_unbound_order.id]['kd_riqi']) self.assertIsNone(by_id[self.legacy_unbound_order.id]['kd_riqi'])

View 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` 都是过滤后的结果。

View File

@@ -47,7 +47,7 @@
| `MeoA` | 曲线 | `PrintingOrder.curve` | 原样保存 | | `MeoA` | 曲线 | `PrintingOrder.curve` | 原样保存 |
| `FidJ` | 电脑位置 | `PrintingOrder.position` | 取分组首条 record 的原始值 | | `FidJ` | 电脑位置 | `PrintingOrder.position` | 取分组首条 record 的原始值 |
| `BeiZhu` | 滚筒注意事项 | `PrintingOrder.rolling_warn` | 原样保存 | | `BeiZhu` | 滚筒注意事项 | `PrintingOrder.rolling_warn` | 原样保存 |
| `KdRiQi` | 单日期 | `PrintingOrder.outgoing_date` | 按 ISO 时间解析并保存 | | `KdRiQi` | 外部开单日期时间 | `PrintingOrder.outgoing_date` / `PrintingOrder.kd_riqi` | 按中国时间解析并保存;即使外部字符串带 `Z`,也按本地业务时间解释;`kd_riqi` 用于 API 展示、筛选和排序 |
| 分组首条原始数据 | 外部来源快照 | `PrintingOrder.external_raw` | 保存 order 级别的原始数据,便于追溯 | | 分组首条原始数据 | 外部来源快照 | `PrintingOrder.external_raw` | 保存 order 级别的原始数据,便于追溯 |
补充说明: 补充说明:
@@ -56,6 +56,80 @@
- 外部 `area` 直接写入 `PrintingOrder.area`;若外部未返回或为空,则统一写入空字符串 - 外部 `area` 直接写入 `PrintingOrder.area`;若外部未返回或为空,则统一写入空字符串
- `BianHaoKD``RiQi` 等当前未单独属性化的字段,保留在 `external_raw` - `BianHaoKD``RiQi` 等当前未单独属性化的字段,保留在 `external_raw`
- `BianHaoKD` 当前样例值类似 `"1.27"`,不按日期强解析 - `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 ### 二、单条 record -> PrintingJob

Binary file not shown.

View File

@@ -1,10 +1,11 @@
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.utils import timezone from django.utils import timezone
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from api_v1.models import AppVersion
APP_VERSION_CACHE_KEY = "app_version:latest" APP_VERSION_CACHE_KEY = "app_version:latest"
@@ -25,6 +26,7 @@ def build_app_version_payload(
download_url: str, download_url: str,
force=False, force=False,
publish_date=None, publish_date=None,
message: str = "",
) -> dict: ) -> dict:
return { return {
"latest_version": { "latest_version": {
@@ -35,24 +37,25 @@ def build_app_version_payload(
"download_url": str(download_url or "").strip(), "download_url": str(download_url or "").strip(),
"force": bool(force), "force": bool(force),
"publish_date": _coerce_publish_date(publish_date), "publish_date": _coerce_publish_date(publish_date),
"message": str(message or ""),
} }
def get_app_version_payload() -> dict: def get_app_version_payload() -> dict:
cached_payload = cache.get(APP_VERSION_CACHE_KEY) app_version = (
if isinstance(cached_payload, dict): AppVersion.objects.filter(is_current=True).order_by('-created_at').first()
try: or AppVersion.objects.order_by('-created_at').first()
version = cached_payload["latest_version"] )
return build_app_version_payload( if app_version:
major=version["major"], return build_app_version_payload(
minor=version["minor"], major=app_version.major,
build=version["build"], minor=app_version.minor,
download_url=cached_payload.get("download_url", ""), build=app_version.build,
force=cached_payload["force"], download_url=app_version.download_url,
publish_date=cached_payload["publish_date"], force=app_version.force,
publish_date=app_version.publish_date,
message=app_version.message,
) )
except (KeyError, TypeError, ValueError):
pass
return build_app_version_payload( return build_app_version_payload(
major=getattr(settings, "APP_LATEST_VERSION_MAJOR", 0), 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", ""), download_url=getattr(settings, "APP_DOWNLOAD_URL", ""),
force=getattr(settings, "APP_FORCE_UPDATE", False), force=getattr(settings, "APP_FORCE_UPDATE", False),
publish_date=getattr(settings, "APP_PUBLISH_DATE", None), 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, download_url: str,
force=False, force=False,
publish_date=None, publish_date=None,
message: str = "",
) -> dict: ) -> dict:
payload = build_app_version_payload( app_version = AppVersion.objects.create(
major=major, major=major,
minor=minor, minor=minor,
build=build, build=build,
download_url=download_url, download_url=download_url,
force=force, 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): class AppVersionView(APIView):

View File

@@ -6,6 +6,7 @@ from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from api_v1.models import AppVersion
from flower.app_version import APP_VERSION_CACHE_KEY from flower.app_version import APP_VERSION_CACHE_KEY
@@ -40,9 +41,84 @@ class AppVersionAPITest(TestCase):
"download_url": "https://example.com/app.apk", "download_url": "https://example.com/app.apk",
"force": True, "force": True,
"publish_date": "2026-07-08", "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): def test_set_app_version_command_updates_cached_api_payload_with_defaults(self):
output = StringIO() output = StringIO()
call_command( call_command(
@@ -65,6 +141,7 @@ class AppVersionAPITest(TestCase):
self.assertEqual(response.data["download_url"], "https://example.com/latest.apk") self.assertEqual(response.data["download_url"], "https://example.com/latest.apk")
self.assertEqual(response.data["force"], False) self.assertEqual(response.data["force"], False)
self.assertEqual(response.data["publish_date"], timezone.localdate().isoformat()) self.assertEqual(response.data["publish_date"], timezone.localdate().isoformat())
self.assertEqual(response.data["message"], "")
self.assertIn("'build': 1001", output.getvalue()) self.assertIn("'build': 1001", output.getvalue())
def test_set_app_version_command_accepts_force_and_publish_date(self): def test_set_app_version_command_accepts_force_and_publish_date(self):
@@ -82,6 +159,8 @@ class AppVersionAPITest(TestCase):
"--force", "--force",
"--publish-date", "--publish-date",
"2026-07-01", "2026-07-01",
"--message",
"强制升级说明",
stdout=output, stdout=output,
) )
@@ -92,3 +171,4 @@ class AppVersionAPITest(TestCase):
self.assertEqual(response.data["download_url"], "https://example.com/force.apk") self.assertEqual(response.data["download_url"], "https://example.com/force.apk")
self.assertEqual(response.data["force"], True) self.assertEqual(response.data["force"], True)
self.assertEqual(response.data["publish_date"], "2026-07-01") self.assertEqual(response.data["publish_date"], "2026-07-01")
self.assertEqual(response.data["message"], "强制升级说明")

View File

@@ -27,7 +27,7 @@ from rest_framework_simplejwt.settings import api_settings
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import ( from rest_framework_simplejwt.views import (
TokenObtainPairView, TokenObtainPairView,
# TokenRefreshView, TokenRefreshView,
) )
from basic_info.models import EmployeeStatusEnum from basic_info.models import EmployeeStatusEnum
@@ -143,7 +143,7 @@ class CustomTokenObtainPairView(TokenObtainPairView):
urlpatterns = [ urlpatterns = [
# JWT 登录 # JWT 登录
path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), 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/error-codes/', ErrorCodeListView.as_view(), name='error_code_list'),
path('api/app-version/', AppVersionView.as_view(), name='app_version'), path('api/app-version/', AppVersionView.as_view(), name='app_version'),

View File

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

View 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'])