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 import admin
from django.contrib.admin import action 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 from .tasks import backup_database
@@ -43,6 +49,24 @@ class MDYPlateOrderStagingAdmin(admin.ModelAdmin):
ordering = ['-created_at'] 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) @admin.register(ApiAuditLog)
class ApiAuditLogAdmin(admin.ModelAdmin): class ApiAuditLogAdmin(admin.ModelAdmin):
"""API审计日志管理""" """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', '产品' PRODUCT = 'product', '产品'
CUSTOMER = 'customer', '客户' CUSTOMER = 'customer', '客户'
PLATE_ORDER = 'plate_order', '开版(明道云)' 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='同步目标') table_name = models.CharField(max_length=50, choices=TableName.choices, verbose_name='同步目标')
page_index = models.PositiveIntegerField(default=1, verbose_name='页码') page_index = models.PositiveIntegerField(default=1, verbose_name='页码')
@@ -173,6 +174,44 @@ class MDYPlateOrderStaging(ModelBase):
return f'{self.mdy_rowid}' 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): class ApiAuditLog(models.Model):
"""API审计日志 - 记录创建操作的历史现场 """API审计日志 - 记录创建操作的历史现场

View File

@@ -19,6 +19,10 @@ from flower.utils import (
fetch_customers_from_mingdaoyun, fetch_customers_from_mingdaoyun,
) )
from api_v1.mdy_plate_order_sync import sync_mdy_plate_orders_to_staging 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__) logger = logging.getLogger(__name__)
@@ -475,6 +479,128 @@ def sync_mdy_plate_orders(
return payload 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 @shared_task
def save_api_audit_log( def save_api_audit_log(
url: str, 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_fabric_received`: 布料是否已收 (`true`/`false`)
- `is_invalid`: 是否作废 (`true`/`false`) - `is_invalid`: 是否作废 (`true`/`false`)
- `area`: 地区 (模糊查询) - `area`: 地区 (模糊查询)
- `outgoing_date_from`/`to`: 出货日期范围 - `outgoing_date_from`/`to`: 出货日期时间范围(支持传 `YYYY-MM-DD` 或日期时间;`to` 传日期时表示包含整天)
- `created_date_from`/`to`: 创建日期范围 - `created_date_from`/`to`: 创建日期范围
- `search`: 全文搜索 (客户名称, 面料, 地区, 工艺) - `search`: 全文搜索 (客户名称, 面料, 地区, 工艺)
- `ordering`: 排序字段 (e.g., `-created_at`) - `ordering`: 排序字段 (e.g., `-created_at`)

View File

@@ -255,6 +255,20 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer):
class PrintingOrderCreateUpdateSerializer(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: class Meta:
model = models.PrintingOrder model = models.PrintingOrder

View File

@@ -1,8 +1,10 @@
""" """
PrintingOrder API 测试 PrintingOrder API 测试
""" """
from datetime import datetime
from django.test import TestCase from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework import status from rest_framework import status
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -111,7 +113,42 @@ class PrintingOrderAPITestCase(TestCase):
self.assertEqual(order.fabric, '纯棉布料') self.assertEqual(order.fabric, '纯棉布料')
# 验证 merchant 自动绑定 # 验证 merchant 自动绑定
self.assertEqual(order.merchant.id, self.merchant.id) 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): 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 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.db.models import CharField, Prefetch from django.db.models import CharField, Prefetch
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
from django.utils.decorators import method_decorator 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.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as django_filters from django_filters import rest_framework as django_filters
from django_filters import IsoDateTimeFilter from django_filters import IsoDateTimeFilter
from datetime import datetime, time, timedelta
from flower.viewsets import LimitedModelViewSet from flower.viewsets import LimitedModelViewSet
from printing import models from printing import models
@@ -75,8 +79,8 @@ class PrintingOrderFilterSet(django_filters.FilterSet):
is_fabric_received = django_filters.BooleanFilter() is_fabric_received = django_filters.BooleanFilter()
is_invalid = django_filters.BooleanFilter() is_invalid = django_filters.BooleanFilter()
area = django_filters.CharFilter(lookup_expr='icontains') area = django_filters.CharFilter(lookup_expr='icontains')
outgoing_date_from = django_filters.DateFilter(field_name='outgoing_date', lookup_expr='gte') outgoing_date_from = django_filters.CharFilter(method='filter_outgoing_date_from')
outgoing_date_to = django_filters.DateFilter(field_name='outgoing_date', lookup_expr='lte') 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_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') 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 model = models.PrintingOrder
fields = ['customer', 'is_urgent', 'is_fabric_received', 'is_invalid'] 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): def filter_created_date_to(self, queryset, name, value):
""" """
过滤 created_at <= 指定日期的 23:59:59.999999 过滤 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** 但代码里确实存在 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 ## 三、如何手动运行推荐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 ## 五、手动跑批推荐management command
位置:`printing/management/commands/tiia_upload_plate_order_images.py` 位置:`printing/management/commands/tiia_upload_plate_order_images.py`

View File

@@ -474,10 +474,18 @@ CELERY_BEAT_SCHEDULE = {
'filename_prefix': 'db-backup', 'filename_prefix': 'db-backup',
}, },
}, },
'daily_plate_order_tiia_image_upload': { # 'daily_plate_order_tiia_image_upload': {
'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia', # 'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia',
'schedule': crontab(hour=3, minute=0), # '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': { 'mdy_product_sync': {
'task': 'api_v1.tasks.sync_mdy_products', 'task': 'api_v1.tasks.sync_mdy_products',
'schedule': crontab(minute='*/10'), '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='布料是否已收') is_fabric_received = models.BooleanField(default=False, verbose_name='布料是否已收')
craft = models.CharField(max_length=100, null=True, blank=True, verbose_name='工艺') craft = models.CharField(max_length=100, null=True, blank=True, verbose_name='工艺')
description = models.TextField(blank=True, null=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='曲线') 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='新加曲线') new_curve = models.CharField(max_length=100, blank=True, null=True, verbose_name='新加曲线')
position = models.TextField(blank=True, null=True, verbose_name='位置') position = models.TextField(blank=True, null=True, verbose_name='位置')

View File

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