1
0
forked from erp-dev/erp

feat: added mdy stagging upload to tiia, and change outgoing_date to datetime type

This commit is contained in:
2026-01-22 17:06:23 +08:00
parent 49ac17cf37
commit 7fd44c64aa
18 changed files with 661 additions and 11 deletions

View File

@@ -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审计日志管理"""

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

View File

@@ -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'),
),
]

View 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='同步目标'),
),
]

View File

@@ -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审计日志 - 记录创建操作的历史现场

View File

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

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

View File

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

View File

@@ -256,6 +256,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
fields = [

View File

@@ -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,6 +113,41 @@ 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):
"""测试获取订单列表"""

View File

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

View File

@@ -0,0 +1,37 @@
### 2026-01-22 工作记录
#### 1) 核查 PlateOrder / PrintingOrder 创建接口是否会写入 merchant_id防止再次产生 merchant=NULL
背景:
- 之前查询接口无数据的根因已确认是历史数据 `merchant_id` 为空;生产已手动回填修复。
- 为避免“新增数据再次写空”,需要确认 create API 是否会自动写入 `merchant_id`
核查结论(仅检查代码逻辑,未修改任何代码):
- `PlateOrder`
- create 时在 `PlateOrderViewSet.perform_create()` 内部注入 `merchant`(来自 `request.user.employee.merchant`),并与 `created_by` 一起写入。
- 若当前用户不存在 `employee``employee.merchant` 为空,则会写入 `merchant=NULL`
- `PrintingOrder`
- create 由 `PrintingOrderCreateUpdateSerializer.create()` 调用 `PrintingOrderService.create_printing_order(...)`
- service 内部会在创建前注入 `merchant`(来自 `request.user.employee.merchant`)与 `created_by`
- 若当前用户不存在 `employee``employee.merchant` 为空,则会写入 `merchant=NULL`
建议(无需改代码即可执行的运维/数据侧约束):
- 确认所有会创建 `PlateOrder/PrintingOrder` 的账号类型都已正确绑定 `employee``employee.merchant` 非空(否则仍可能产生新 “merchant=NULL” 数据)。
#### 2) 新增MDY 开版暂存MDYPlateOrderStaging“开版图”上传到腾讯云 TIIA 图库(增量定时任务)
- 数据源:`api_v1.models.MDYPlateOrderStaging.raw["62d52f4b8d2972284492dd27"]`(开版图 Attachment
- URL 选择策略:`original_file_full_path` 优先,`DownloadUrl` 候补(不再使用带 token 的 `preview_url`
- 新增 service`api_v1/mdy_plate_order_staging_tiia_upload.py`
- 新增任务:`api_v1.tasks.upload_mdy_plate_order_staging_images_to_tencent_tiia`
- 游标:`api_v1.models.DataSync``table_name=mdy_plate_order_staging_tiia_upload``last_rowid` 存 staging 自增 id
- 失败落库:`api_v1.models.MDYPlateOrderStagingTiiaUploadFailure`Django Admin 可查)
- 新增 Beat`flower/settings.py``daily_mdy_plate_order_staging_tiia_image_upload`(默认 03:20
#### 3) PrintingOrder.outgoing_date 升级为日期时间DateTime并保持入参兼容
- 模型字段:`printing.models.PrintingOrder.outgoing_date``DateField` 升级为 `DateTimeField`(迁移:`printing/migrations/0032_alter_printingorder_outgoing_date.py`
- API 入参兼容:仍可传 `YYYY-MM-DD`,会自动视为当天 `00:00:00`
- API 返回值:统一返回日期时间字符串(包含时间部分,即使为 `00:00:00`
- 列表筛选:`outgoing_date_from/outgoing_date_to` 支持传日期或日期时间;其中 `outgoing_date_to` 传日期时按“包含整天”处理(`< 次日 00:00:00`
- 测试:严格执行 `api_v1.views.printing.test_api`(新增用例覆盖 `outgoing_date_to` 的整天包含语义),全绿

View File

@@ -47,6 +47,11 @@ Command 默认会读取/写入 checkpoint可以通过参数关闭。
但代码里确实存在 PlateOrder 的 Celery task见下文只是 **未配置到 beat schedule**
补充:**与“同步”不同**,项目已新增一条“开版暂存 → 腾讯云 TIIA 图库上传”的定时任务(用于把暂存表中的开版图入库到腾讯图库),它是单独的 beat 项,详见:
- `docs/tiia_image_gallery.md` 的 “四点五、MDY 开版暂存图片上传到图库”
- `flower/settings.py``CELERY_BEAT_SCHEDULE['daily_mdy_plate_order_staging_tiia_image_upload']`(默认 03:20
---
## 三、如何手动运行推荐management command

View File

@@ -105,6 +105,58 @@
---
## 四点五、MDY 开版暂存MDYPlateOrderStaging图片上传到图库CreateImage
### 4.5.1 目标数据源与字段(明道云暂存)
数据源:
- 模型:`api_v1.models.MDYPlateOrderStaging`
- 图片字段:`raw["62d52f4b8d2972284492dd27"]`明道云开版主表字段“开版图”Attachment
单个附件项的 URL 取值策略(当前约定):
- **优先**`original_file_full_path`(你确认的主用字段)
- **候补**`DownloadUrl`
- 不再使用:`preview_url`(通常带 token/过期参数,不适合作为长期回显 URL
### 4.5.2 Service上传单条暂存记录
位置:`api_v1/mdy_plate_order_staging_tiia_upload.py`
- `upload_mdy_plate_order_staging_plate_images_to_tencent_tiia(staging, rate_limiter=None, dry_run=False)`
- `EntityId`:使用 `staging.mdy_rowid`
- 每张图调用:`api_v1.utils.tencentcloud_tiia.upload_image_url_to_tencent_tiia(image_url, entity_id, custom_content=image_url)`
- 限流:`SimpleRateLimiter(qps=settings.TENCENTCLOUD_TIIA_QPS)`(默认 10
- 单张失败不影响其它图片,返回 `successes/failures` 明细
### 4.5.3 定时任务Celery Beat
位置:`api_v1/tasks.py`
- task`upload_mdy_plate_order_staging_images_to_tencent_tiia(batch_size=200, dry_run=False)`
- **游标**`api_v1.models.DataSync``table_name=mdy_plate_order_staging_tiia_upload`),用 `last_rowid` 存储 staging 表自增 `id`
- **失败落库**`api_v1.models.MDYPlateOrderStagingTiiaUploadFailure`(同日同 rowid 去重attempts 累加)
Beat 配置位置:`flower/settings.py`
- `CELERY_BEAT_SCHEDULE['daily_mdy_plate_order_staging_tiia_image_upload']`
- 默认:**03:20** 运行
- kwargs 默认值:
- `batch_size=500`
- `dry_run=False`
手动触发(示例):
- `uv run python manage.py shell -c "from api_v1.tasks import upload_mdy_plate_order_staging_images_to_tencent_tiia; r=upload_mdy_plate_order_staging_images_to_tencent_tiia.delay(batch_size=50, dry_run=True); print(r.id)"`
### 4.5.4 迁移与 Admin
- 迁移:`api_v1/migrations/0010_mdy_plate_order_staging_tiia_upload_failure.py`
- Admin`api_v1/admin.py` 已注册 `MDYPlateOrderStagingTiiaUploadFailure`
---
## 五、手动跑批推荐management command
位置:`printing/management/commands/tiia_upload_plate_order_images.py`

View File

@@ -474,10 +474,18 @@ CELERY_BEAT_SCHEDULE = {
'filename_prefix': 'db-backup',
},
},
'daily_plate_order_tiia_image_upload': {
'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia',
'schedule': crontab(hour=3, minute=0),
},
# 'daily_plate_order_tiia_image_upload': {
# 'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia',
# 'schedule': crontab(hour=3, minute=0),
# },
# 'daily_mdy_plate_order_staging_tiia_image_upload': {
# 'task': 'api_v1.tasks.upload_mdy_plate_order_staging_images_to_tencent_tiia',
# 'schedule': crontab(hour=3, minute=20),
# 'kwargs': {
# 'batch_size': 500,
# 'dry_run': False,
# },
# },
'mdy_product_sync': {
'task': 'api_v1.tasks.sync_mdy_products',
'schedule': crontab(minute='*/10'),

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-01-22 08:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('printing', '0031_plateorder_tiia_upload_failure'),
]
operations = [
migrations.AlterField(
model_name='printingorder',
name='outgoing_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='出货日期'),
),
]

View File

@@ -332,7 +332,7 @@ class PrintingOrder(ModelBase):
is_fabric_received = models.BooleanField(default=False, verbose_name='布料是否已收')
craft = models.CharField(max_length=100, null=True, blank=True, verbose_name='工艺')
description = models.TextField(blank=True, null=True, verbose_name='订单描述')
outgoing_date = models.DateField(null=True, blank=True, verbose_name='出货日期')
outgoing_date = models.DateTimeField(null=True, blank=True, verbose_name='出货日期')
curve = models.CharField(max_length=100, blank=True, null=True, verbose_name='曲线')
new_curve = models.CharField(max_length=100, blank=True, null=True, verbose_name='新加曲线')
position = models.TextField(blank=True, null=True, verbose_name='位置')

View File

@@ -635,8 +635,8 @@ components:
description: 订单描述。
outgoing_date:
type: string
format: date
description: 计划出货日期。
format: date-time
description: 计划出货日期时间(可仅传 YYYY-MM-DD将自动视为 00:00:00
curve:
type: string
description: 曲线。