forked from erp-dev/erp
feat: added mdy stagging upload to tiia, and change outgoing_date to datetime type
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import action
|
||||
from api_v1.models import UploadedFile, DataSync, MDYPlateOrderStaging, ApiAuditLog
|
||||
from api_v1.models import (
|
||||
UploadedFile,
|
||||
DataSync,
|
||||
MDYPlateOrderStaging,
|
||||
MDYPlateOrderStagingTiiaUploadFailure,
|
||||
ApiAuditLog,
|
||||
)
|
||||
from .tasks import backup_database
|
||||
|
||||
|
||||
@@ -43,6 +49,24 @@ class MDYPlateOrderStagingAdmin(admin.ModelAdmin):
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
@admin.register(MDYPlateOrderStagingTiiaUploadFailure)
|
||||
class MDYPlateOrderStagingTiiaUploadFailureAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'id',
|
||||
'run_date',
|
||||
'mdy_rowid',
|
||||
'staging_id',
|
||||
'attempts',
|
||||
'last_attempt_at',
|
||||
'created_at',
|
||||
]
|
||||
list_filter = ['run_date', 'created_at']
|
||||
search_fields = ['mdy_rowid', 'error']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
date_hierarchy = 'created_at'
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
@admin.register(ApiAuditLog)
|
||||
class ApiAuditLogAdmin(admin.ModelAdmin):
|
||||
"""API审计日志管理"""
|
||||
|
||||
149
api_v1/mdy_plate_order_staging_tiia_upload.py
Normal file
149
api_v1/mdy_plate_order_staging_tiia_upload.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Upload images from MDYPlateOrderStaging ("明道云开版暂存") to Tencent Cloud TIIA gallery.
|
||||
|
||||
Design goals:
|
||||
- Reuse existing Tencent TIIA uploader: api_v1.utils.tencentcloud_tiia.upload_image_url_to_tencent_tiia
|
||||
- Extract image URL from the MDY "开版图" Attachment field:
|
||||
- Prefer `original_file_full_path` (user requirement)
|
||||
- Fallback to `DownloadUrl`
|
||||
- Per-image error isolation: keep going and return failures list
|
||||
- Rate limit: default to settings.TENCENTCLOUD_TIIA_QPS (10 qps)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from api_v1.models import MDYPlateOrderStaging
|
||||
from api_v1.utils.tencentcloud_tiia import SimpleRateLimiter, upload_image_url_to_tencent_tiia
|
||||
from flower.utils.mingdaoyun.relations import normalize_mdy_value
|
||||
|
||||
|
||||
_MDY_PLATE_IMAGES_CONTROL_ID = "62d52f4b8d2972284492dd27" # 开版图(Attachment)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AttachmentUrlPickResult:
|
||||
url: str | None
|
||||
source_field: str | None
|
||||
|
||||
|
||||
def _pick_attachment_url(item: dict[str, Any]) -> _AttachmentUrlPickResult:
|
||||
"""
|
||||
Pick the best uploadable URL from a single MDY Attachment item dict.
|
||||
|
||||
Priority (per user requirement):
|
||||
1) original_file_full_path / original_file_path
|
||||
2) DownloadUrl / download_url
|
||||
"""
|
||||
# 1) original_file_full_path (observed in real data)
|
||||
for k in ("original_file_full_path", "original_file_path"):
|
||||
v = item.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return _AttachmentUrlPickResult(url=v.strip(), source_field=k)
|
||||
|
||||
# 2) DownloadUrl (observed in real data; note the exact case)
|
||||
for k in ("DownloadUrl", "download_url", "downloadUrl"):
|
||||
v = item.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return _AttachmentUrlPickResult(url=v.strip(), source_field=k)
|
||||
|
||||
return _AttachmentUrlPickResult(url=None, source_field=None)
|
||||
|
||||
|
||||
def _extract_plate_image_attachment_items(staging: MDYPlateOrderStaging) -> list[dict[str, Any]]:
|
||||
raw = staging.raw if isinstance(staging.raw, dict) else {}
|
||||
value = normalize_mdy_value(raw.get(_MDY_PLATE_IMAGES_CONTROL_ID))
|
||||
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in value:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def upload_mdy_plate_order_staging_plate_images_to_tencent_tiia(
|
||||
*,
|
||||
staging: MDYPlateOrderStaging,
|
||||
rate_limiter: SimpleRateLimiter | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Upload "开版图" images for a single staging record.
|
||||
|
||||
EntityId:
|
||||
- Use `mdy_rowid` (stable unique string).
|
||||
"""
|
||||
entity_id = str(staging.mdy_rowid)
|
||||
items = _extract_plate_image_attachment_items(staging)
|
||||
|
||||
failures: list[dict[str, Any]] = []
|
||||
successes: list[dict[str, Any]] = []
|
||||
|
||||
for idx, item in enumerate(items):
|
||||
picked = _pick_attachment_url(item)
|
||||
if not picked.url:
|
||||
failures.append(
|
||||
{
|
||||
"index": idx,
|
||||
"reason": "missing_url",
|
||||
"mdy_keys": sorted(item.keys()),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if rate_limiter is not None:
|
||||
rate_limiter.wait()
|
||||
if dry_run:
|
||||
resp = {"dry_run": True}
|
||||
else:
|
||||
# Store the original URL in CustomContent (default behavior if not passed).
|
||||
resp = upload_image_url_to_tencent_tiia(
|
||||
image_url=picked.url,
|
||||
entity_id=entity_id,
|
||||
custom_content=picked.url,
|
||||
)
|
||||
successes.append(
|
||||
{
|
||||
"index": idx,
|
||||
"source_field": picked.source_field,
|
||||
"image_url": picked.url,
|
||||
"resp": resp,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
failures.append(
|
||||
{
|
||||
"index": idx,
|
||||
"source_field": picked.source_field,
|
||||
"image_url": picked.url,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"mdy_rowid": staging.mdy_rowid,
|
||||
"staging_id": staging.id,
|
||||
"entity_id": entity_id,
|
||||
"total": len(items),
|
||||
"success_count": len(successes),
|
||||
"failure_count": len(failures),
|
||||
"successes": successes,
|
||||
"failures": failures,
|
||||
}
|
||||
|
||||
|
||||
def build_default_tiia_rate_limiter() -> SimpleRateLimiter:
|
||||
qps = float(getattr(settings, "TENCENTCLOUD_TIIA_QPS", 10) or 10)
|
||||
return SimpleRateLimiter(qps=qps)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api_v1', '0009_apiauditlog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MDYPlateOrderStagingTiiaUploadFailure',
|
||||
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='更新时间')),
|
||||
('run_date', models.DateField(db_index=True, verbose_name='任务日期')),
|
||||
('mdy_rowid', models.CharField(db_index=True, max_length=64, verbose_name='明道云 RowID')),
|
||||
('staging_id', models.PositiveBigIntegerField(blank=True, db_index=True, null=True, verbose_name='暂存表ID')),
|
||||
('error', models.TextField(blank=True, verbose_name='错误摘要')),
|
||||
('details', models.JSONField(blank=True, default=list, help_text='通常存储上传 service 返回的失败条目列表', verbose_name='错误详情')),
|
||||
('attempts', models.PositiveIntegerField(default=0, verbose_name='尝试次数')),
|
||||
('last_attempt_at', models.DateTimeField(blank=True, null=True, verbose_name='最后尝试时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '开版暂存图片上传失败记录',
|
||||
'verbose_name_plural': '开版暂存图片上传失败记录',
|
||||
'db_table': 'api_mdy_plate_order_staging_tiia_upload_failure',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='mdyplateorderstagingtiiauploadfailure',
|
||||
constraint=models.UniqueConstraint(fields=('run_date', 'mdy_rowid'), name='uniq_mdy_po_staging_tiia_fail_run_date_rowid'),
|
||||
),
|
||||
]
|
||||
|
||||
18
api_v1/migrations/0011_alter_datasync_table_name.py
Normal file
18
api_v1/migrations/0011_alter_datasync_table_name.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2026-01-22 07:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api_v1', '0010_mdy_plate_order_staging_tiia_upload_failure'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='datasync',
|
||||
name='table_name',
|
||||
field=models.CharField(choices=[('product', '产品'), ('customer', '客户'), ('plate_order', '开版(明道云)'), ('mdy_plate_order_staging_tiia_upload', '开版暂存-腾讯图库上传')], max_length=50, verbose_name='同步目标'),
|
||||
),
|
||||
]
|
||||
@@ -92,6 +92,7 @@ class DataSync(ModelBase):
|
||||
PRODUCT = 'product', '产品'
|
||||
CUSTOMER = 'customer', '客户'
|
||||
PLATE_ORDER = 'plate_order', '开版(明道云)'
|
||||
MDY_PLATE_ORDER_STAGING_TIIA_UPLOAD = 'mdy_plate_order_staging_tiia_upload', '开版暂存-腾讯图库上传'
|
||||
|
||||
table_name = models.CharField(max_length=50, choices=TableName.choices, verbose_name='同步目标')
|
||||
page_index = models.PositiveIntegerField(default=1, verbose_name='页码')
|
||||
@@ -173,6 +174,44 @@ class MDYPlateOrderStaging(ModelBase):
|
||||
return f'{self.mdy_rowid}'
|
||||
|
||||
|
||||
class MDYPlateOrderStagingTiiaUploadFailure(ModelBase):
|
||||
"""
|
||||
明道云开版暂存(MDYPlateOrderStaging)图片上传到腾讯云 TIIA 失败记录。
|
||||
|
||||
说明:
|
||||
- 用于定时任务/批处理的失败落库,便于后续排查与补偿重试
|
||||
- 以 (run_date, mdy_rowid) 唯一,避免同一天重复刷屏
|
||||
"""
|
||||
|
||||
run_date = models.DateField(db_index=True, verbose_name='任务日期')
|
||||
mdy_rowid = models.CharField(max_length=64, db_index=True, verbose_name='明道云 RowID')
|
||||
staging_id = models.PositiveBigIntegerField(null=True, blank=True, db_index=True, verbose_name='暂存表ID')
|
||||
error = models.TextField(blank=True, verbose_name='错误摘要')
|
||||
details = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
verbose_name='错误详情',
|
||||
help_text='通常存储上传 service 返回的失败条目列表',
|
||||
)
|
||||
attempts = models.PositiveIntegerField(default=0, verbose_name='尝试次数')
|
||||
last_attempt_at = models.DateTimeField(null=True, blank=True, verbose_name='最后尝试时间')
|
||||
|
||||
class Meta:
|
||||
db_table = 'api_mdy_plate_order_staging_tiia_upload_failure'
|
||||
verbose_name = '开版暂存图片上传失败记录'
|
||||
verbose_name_plural = '开版暂存图片上传失败记录'
|
||||
ordering = ['-created_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['run_date', 'mdy_rowid'],
|
||||
name='uniq_mdy_po_staging_tiia_fail_run_date_rowid',
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.run_date} {self.mdy_rowid}'
|
||||
|
||||
|
||||
class ApiAuditLog(models.Model):
|
||||
"""API审计日志 - 记录创建操作的历史现场
|
||||
|
||||
|
||||
126
api_v1/tasks.py
126
api_v1/tasks.py
@@ -19,6 +19,10 @@ from flower.utils import (
|
||||
fetch_customers_from_mingdaoyun,
|
||||
)
|
||||
from api_v1.mdy_plate_order_sync import sync_mdy_plate_orders_to_staging
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -475,6 +479,128 @@ def sync_mdy_plate_orders(
|
||||
return payload
|
||||
|
||||
|
||||
def _record_mdy_plate_order_staging_tiia_failure(
|
||||
*,
|
||||
run_date,
|
||||
staging: api_models.MDYPlateOrderStaging,
|
||||
error: str,
|
||||
details: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
记录“开版暂存 -> TIIA 上传”失败(同一天同一 mdy_rowid 去重,attempts 累加)。
|
||||
"""
|
||||
obj, _created = api_models.MDYPlateOrderStagingTiiaUploadFailure.objects.get_or_create(
|
||||
run_date=run_date,
|
||||
mdy_rowid=staging.mdy_rowid,
|
||||
defaults={
|
||||
"staging_id": staging.id,
|
||||
"error": error or "",
|
||||
"details": details or [],
|
||||
"attempts": 0,
|
||||
"last_attempt_at": timezone.now(),
|
||||
},
|
||||
)
|
||||
obj.staging_id = staging.id
|
||||
obj.error = error or ""
|
||||
obj.details = details or []
|
||||
obj.attempts = int(obj.attempts or 0) + 1
|
||||
obj.last_attempt_at = timezone.now()
|
||||
obj.save(update_fields=["staging_id", "error", "details", "attempts", "last_attempt_at", "updated_at"])
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def upload_mdy_plate_order_staging_images_to_tencent_tiia(
|
||||
self,
|
||||
batch_size: int = 200,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
):
|
||||
"""
|
||||
将 MDYPlateOrderStaging 的“开版图(Attachment)”上传到腾讯云 TIIA 图库(增量)。
|
||||
|
||||
- 游标:使用 api_data_sync.last_rowid 存储 staging 表的 last_id(自增主键)
|
||||
- 失败:写入 api_mdy_plate_order_staging_tiia_upload_failure(不影响后续继续跑)
|
||||
- 限流:按 settings.TENCENTCLOUD_TIIA_QPS(默认 10 qps)
|
||||
"""
|
||||
run_date = timezone.localdate()
|
||||
limiter = build_default_tiia_rate_limiter()
|
||||
|
||||
last_sync = (
|
||||
api_models.DataSync.objects.filter(
|
||||
table_name=api_models.DataSync.TableName.MDY_PLATE_ORDER_STAGING_TIIA_UPLOAD
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
last_id = 0
|
||||
if last_sync and (last_sync.last_rowid or "").strip():
|
||||
try:
|
||||
last_id = int(last_sync.last_rowid)
|
||||
except ValueError:
|
||||
last_id = 0
|
||||
|
||||
qs = api_models.MDYPlateOrderStaging.objects.filter(id__gt=last_id).order_by("id")
|
||||
if batch_size and batch_size > 0:
|
||||
qs = qs[:batch_size]
|
||||
|
||||
processed = 0
|
||||
failed_records = 0
|
||||
latest_id = last_id
|
||||
latest_ctime = last_sync.last_ctime if last_sync else None
|
||||
|
||||
for staging in qs:
|
||||
processed += 1
|
||||
latest_id = staging.id
|
||||
if staging.ctime:
|
||||
latest_ctime = staging.ctime
|
||||
|
||||
try:
|
||||
result = upload_mdy_plate_order_staging_plate_images_to_tencent_tiia(
|
||||
staging=staging,
|
||||
rate_limiter=limiter,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if int(result.get("failure_count") or 0) > 0:
|
||||
failed_records += 1
|
||||
_record_mdy_plate_order_staging_tiia_failure(
|
||||
run_date=run_date,
|
||||
staging=staging,
|
||||
error="部分图片上传失败" if int(result.get("success_count") or 0) > 0 else "图片上传失败",
|
||||
details=[{"result": result}],
|
||||
)
|
||||
except Exception as exc:
|
||||
failed_records += 1
|
||||
_record_mdy_plate_order_staging_tiia_failure(
|
||||
run_date=run_date,
|
||||
staging=staging,
|
||||
error=str(exc),
|
||||
details=[{"error": str(exc), "mdy_rowid": staging.mdy_rowid, "staging_id": staging.id}],
|
||||
)
|
||||
|
||||
api_models.DataSync.objects.create(
|
||||
table_name=api_models.DataSync.TableName.MDY_PLATE_ORDER_STAGING_TIIA_UPLOAD,
|
||||
page_index=1,
|
||||
page_size=batch_size,
|
||||
synced_rows=processed,
|
||||
total_count=api_models.MDYPlateOrderStaging.objects.count(),
|
||||
last_ctime=latest_ctime,
|
||||
last_rowid=str(latest_id) if latest_id else str(last_id),
|
||||
note=f"id scan; dry_run={dry_run}",
|
||||
)
|
||||
|
||||
payload = {
|
||||
"task_id": self.request.id,
|
||||
"processed": processed,
|
||||
"failed_records": failed_records,
|
||||
"last_id": latest_id,
|
||||
"last_ctime": latest_ctime.isoformat() if latest_ctime else None,
|
||||
"dry_run": dry_run,
|
||||
"batch_size": batch_size,
|
||||
}
|
||||
logger.info("MDY 开版暂存图片上传到 TIIA 完成: %s", payload)
|
||||
return payload
|
||||
|
||||
|
||||
@shared_task
|
||||
def save_api_audit_log(
|
||||
url: str,
|
||||
|
||||
30
api_v1/test_mdy_plate_order_staging_tiia_upload.py
Normal file
30
api_v1/test_mdy_plate_order_staging_tiia_upload.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from api_v1.mdy_plate_order_staging_tiia_upload import _pick_attachment_url
|
||||
|
||||
|
||||
class MDYPlateOrderStagingTiiaUploadUrlPickTest(SimpleTestCase):
|
||||
def test_prefers_original_file_full_path(self):
|
||||
item = {
|
||||
"original_file_full_path": "https://example.com/original.png",
|
||||
"DownloadUrl": "https://example.com/download.png",
|
||||
"preview_url": "https://example.com/preview.png?token=xxx",
|
||||
}
|
||||
picked = _pick_attachment_url(item)
|
||||
self.assertEqual(picked.url, "https://example.com/original.png")
|
||||
self.assertEqual(picked.source_field, "original_file_full_path")
|
||||
|
||||
def test_fallbacks_to_download_url(self):
|
||||
item = {
|
||||
"DownloadUrl": "https://example.com/download.png",
|
||||
}
|
||||
picked = _pick_attachment_url(item)
|
||||
self.assertEqual(picked.url, "https://example.com/download.png")
|
||||
self.assertEqual(picked.source_field, "DownloadUrl")
|
||||
|
||||
def test_returns_none_when_missing(self):
|
||||
item = {"preview_url": "https://example.com/preview.png?token=xxx"}
|
||||
picked = _pick_attachment_url(item)
|
||||
self.assertIsNone(picked.url)
|
||||
self.assertIsNone(picked.source_field)
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
- `is_fabric_received`: 布料是否已收 (`true`/`false`)
|
||||
- `is_invalid`: 是否作废 (`true`/`false`)
|
||||
- `area`: 地区 (模糊查询)
|
||||
- `outgoing_date_from`/`to`: 出货日期范围
|
||||
- `outgoing_date_from`/`to`: 出货日期时间范围(支持传 `YYYY-MM-DD` 或日期时间;`to` 传日期时表示包含整天)
|
||||
- `created_date_from`/`to`: 创建日期范围
|
||||
- `search`: 全文搜索 (客户名称, 面料, 地区, 工艺)
|
||||
- `ordering`: 排序字段 (e.g., `-created_at`)
|
||||
|
||||
@@ -255,6 +255,20 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer):
|
||||
|
||||
class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
"""印染订单创建/更新序列化器"""
|
||||
|
||||
# 兼容前端只传日期(YYYY-MM-DD):会解析为 00:00:00
|
||||
# 同时也接受 ISO8601 datetime(含时区)。
|
||||
outgoing_date = 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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
PrintingOrder API 测试
|
||||
"""
|
||||
from datetime import datetime
|
||||
from django.test import TestCase
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -111,7 +113,42 @@ class PrintingOrderAPITestCase(TestCase):
|
||||
self.assertEqual(order.fabric, '纯棉布料')
|
||||
# 验证 merchant 自动绑定
|
||||
self.assertEqual(order.merchant.id, self.merchant.id)
|
||||
# 验证 outgoing_date 接受日期字符串并归一为 00:00:00(按当前时区)
|
||||
self.assertIsNotNone(order.outgoing_date)
|
||||
local_dt = timezone.localtime(order.outgoing_date)
|
||||
self.assertEqual(str(local_dt.date()), '2025-11-20')
|
||||
self.assertEqual(local_dt.hour, 0)
|
||||
self.assertEqual(local_dt.minute, 0)
|
||||
|
||||
def test_filter_outgoing_date_to_includes_whole_day(self):
|
||||
"""
|
||||
outgoing_date_to 传 YYYY-MM-DD 时应包含整天:
|
||||
- outgoing_date < 次日 00:00:00
|
||||
"""
|
||||
tz = timezone.get_current_timezone()
|
||||
d0 = timezone.make_aware(datetime(2025, 11, 20, 23, 0, 0), tz)
|
||||
d1 = timezone.make_aware(datetime(2025, 11, 21, 0, 0, 0), tz)
|
||||
printing_models.PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
fabric='布料A',
|
||||
width='150cm',
|
||||
outgoing_date=d0,
|
||||
)
|
||||
printing_models.PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=self.customer,
|
||||
fabric='布料B',
|
||||
width='150cm',
|
||||
outgoing_date=d1,
|
||||
)
|
||||
|
||||
resp = self.client.get('/api/v1/printing-orders/?limit=50&offset=0&outgoing_date_to=2025-11-20')
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
fabrics = [row['fabric'] for row in resp.data['results']]
|
||||
self.assertIn('布料A', fabrics)
|
||||
self.assertNotIn('布料B', fabrics)
|
||||
|
||||
def test_list_printing_orders(self):
|
||||
"""测试获取订单列表"""
|
||||
# 创建测试订单
|
||||
|
||||
@@ -7,14 +7,18 @@ from rest_framework.response import Response
|
||||
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, Prefetch
|
||||
from django.db.models.functions import Cast, Coalesce
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.decorators.http import condition
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date, parse_datetime
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters import rest_framework as django_filters
|
||||
from django_filters import IsoDateTimeFilter
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from flower.viewsets import LimitedModelViewSet
|
||||
from printing import models
|
||||
@@ -75,8 +79,8 @@ class PrintingOrderFilterSet(django_filters.FilterSet):
|
||||
is_fabric_received = django_filters.BooleanFilter()
|
||||
is_invalid = django_filters.BooleanFilter()
|
||||
area = django_filters.CharFilter(lookup_expr='icontains')
|
||||
outgoing_date_from = django_filters.DateFilter(field_name='outgoing_date', lookup_expr='gte')
|
||||
outgoing_date_to = django_filters.DateFilter(field_name='outgoing_date', lookup_expr='lte')
|
||||
outgoing_date_from = django_filters.CharFilter(method='filter_outgoing_date_from')
|
||||
outgoing_date_to = django_filters.CharFilter(method='filter_outgoing_date_to')
|
||||
created_date_from = django_filters.DateFilter(field_name='created_at', lookup_expr='gte')
|
||||
created_date_to = django_filters.DateFilter(field_name='created_at', method='filter_created_date_to')
|
||||
|
||||
@@ -84,6 +88,58 @@ class PrintingOrderFilterSet(django_filters.FilterSet):
|
||||
model = models.PrintingOrder
|
||||
fields = ['customer', 'is_urgent', 'is_fabric_received', 'is_invalid']
|
||||
|
||||
@staticmethod
|
||||
def _parse_date_or_datetime(value: str | None) -> tuple[datetime | None, bool]:
|
||||
"""
|
||||
解析日期/日期时间字符串为“当前时区”的 aware datetime。
|
||||
|
||||
返回:
|
||||
- (dt, has_time)
|
||||
- has_time=True:输入包含时间(datetime)
|
||||
- has_time=False:输入为纯日期(date-only)
|
||||
"""
|
||||
s = (value or '').strip()
|
||||
if not s:
|
||||
return None, False
|
||||
|
||||
tz = timezone.get_current_timezone()
|
||||
|
||||
# 注意:Django 的 parse_datetime 在某些版本会把纯日期(YYYY-MM-DD)也解析成 00:00:00,
|
||||
# 这会让“to=日期”语义从“包含整天”退化为“<= 当天 00:00:00”。因此这里先尝试 parse_date。
|
||||
d = parse_date(s)
|
||||
if d is not None:
|
||||
dt0 = timezone.make_aware(datetime.combine(d, time.min), tz)
|
||||
return dt0, False
|
||||
|
||||
dt = parse_datetime(s)
|
||||
if dt is not None:
|
||||
if timezone.is_naive(dt):
|
||||
dt = timezone.make_aware(dt, tz)
|
||||
else:
|
||||
dt = timezone.localtime(dt, tz)
|
||||
return dt, True
|
||||
|
||||
return None, False
|
||||
|
||||
def filter_outgoing_date_from(self, queryset, name, value):
|
||||
dt, _has_time = self._parse_date_or_datetime(value)
|
||||
if dt is None:
|
||||
# 与 DRF/DjangoFilterBackend 行为保持一致:参数非法返回 400
|
||||
raise ValidationError('outgoing_date_from 必须是日期或日期时间')
|
||||
return queryset.filter(outgoing_date__gte=dt)
|
||||
|
||||
def filter_outgoing_date_to(self, queryset, name, value):
|
||||
dt, has_time = self._parse_date_or_datetime(value)
|
||||
if dt is None:
|
||||
raise ValidationError('outgoing_date_to 必须是日期或日期时间')
|
||||
|
||||
# 纯日期:包含整天 => outgoing_date < 次日 00:00:00
|
||||
if not has_time:
|
||||
return queryset.filter(outgoing_date__lt=dt + timedelta(days=1))
|
||||
|
||||
# 日期时间:按“<=”包含到秒
|
||||
return queryset.filter(outgoing_date__lte=dt)
|
||||
|
||||
def filter_created_date_to(self, queryset, name, value):
|
||||
"""
|
||||
过滤 created_at <= 指定日期的 23:59:59.999999
|
||||
|
||||
Reference in New Issue
Block a user