diff --git a/api_v1/admin.py b/api_v1/admin.py index 36781c5..e47e534 100644 --- a/api_v1/admin.py +++ b/api_v1/admin.py @@ -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审计日志管理""" diff --git a/api_v1/mdy_plate_order_staging_tiia_upload.py b/api_v1/mdy_plate_order_staging_tiia_upload.py new file mode 100644 index 0000000..23d0288 --- /dev/null +++ b/api_v1/mdy_plate_order_staging_tiia_upload.py @@ -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) + diff --git a/api_v1/migrations/0010_mdy_plate_order_staging_tiia_upload_failure.py b/api_v1/migrations/0010_mdy_plate_order_staging_tiia_upload_failure.py new file mode 100644 index 0000000..f114d29 --- /dev/null +++ b/api_v1/migrations/0010_mdy_plate_order_staging_tiia_upload_failure.py @@ -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'), + ), + ] + diff --git a/api_v1/migrations/0011_alter_datasync_table_name.py b/api_v1/migrations/0011_alter_datasync_table_name.py new file mode 100644 index 0000000..6d61d47 --- /dev/null +++ b/api_v1/migrations/0011_alter_datasync_table_name.py @@ -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='同步目标'), + ), + ] diff --git a/api_v1/models.py b/api_v1/models.py index d1b0ff9..28b3771 100644 --- a/api_v1/models.py +++ b/api_v1/models.py @@ -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审计日志 - 记录创建操作的历史现场 diff --git a/api_v1/tasks.py b/api_v1/tasks.py index 68b4c28..afac0ad 100644 --- a/api_v1/tasks.py +++ b/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, diff --git a/api_v1/test_mdy_plate_order_staging_tiia_upload.py b/api_v1/test_mdy_plate_order_staging_tiia_upload.py new file mode 100644 index 0000000..9ce1d3c --- /dev/null +++ b/api_v1/test_mdy_plate_order_staging_tiia_upload.py @@ -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) + diff --git a/api_v1/views/printing/API.md b/api_v1/views/printing/API.md index 86a339d..3192046 100644 --- a/api_v1/views/printing/API.md +++ b/api_v1/views/printing/API.md @@ -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`) diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 9035702..e2091de 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -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 diff --git a/api_v1/views/printing/test_api.py b/api_v1/views/printing/test_api.py index f3720fa..3fe4fc7 100644 --- a/api_v1/views/printing/test_api.py +++ b/api_v1/views/printing/test_api.py @@ -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): """测试获取订单列表""" # 创建测试订单 diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index e495123..3295cce 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -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 diff --git a/docs/2026-01-22_summary.md b/docs/2026-01-22_summary.md new file mode 100644 index 0000000..24e6a60 --- /dev/null +++ b/docs/2026-01-22_summary.md @@ -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` 的整天包含语义),全绿 diff --git a/docs/mdy_plate_order_sync.md b/docs/mdy_plate_order_sync.md index f28c67b..088165d 100644 --- a/docs/mdy_plate_order_sync.md +++ b/docs/mdy_plate_order_sync.md @@ -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) diff --git a/docs/tiia_image_gallery.md b/docs/tiia_image_gallery.md index 7a6e153..8e429c8 100644 --- a/docs/tiia_image_gallery.md +++ b/docs/tiia_image_gallery.md @@ -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` diff --git a/flower/settings.py b/flower/settings.py index 128205c..9fc6d26 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -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'), diff --git a/printing/migrations/0032_alter_printingorder_outgoing_date.py b/printing/migrations/0032_alter_printingorder_outgoing_date.py new file mode 100644 index 0000000..1232334 --- /dev/null +++ b/printing/migrations/0032_alter_printingorder_outgoing_date.py @@ -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='出货日期'), + ), + ] diff --git a/printing/models.py b/printing/models.py index baa4212..b167549 100644 --- a/printing/models.py +++ b/printing/models.py @@ -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='位置') diff --git a/printing/printing_api.yml b/printing/printing_api.yml index 216bc32..693b397 100644 --- a/printing/printing_api.yml +++ b/printing/printing_api.yml @@ -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: 曲线。