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 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审计日志管理"""
|
||||||
|
|||||||
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', '产品'
|
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审计日志 - 记录创建操作的历史现场
|
||||||
|
|
||||||
|
|||||||
126
api_v1/tasks.py
126
api_v1/tasks.py
@@ -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,
|
||||||
|
|||||||
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_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`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
"""测试获取订单列表"""
|
"""测试获取订单列表"""
|
||||||
# 创建测试订单
|
# 创建测试订单
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
docs/2026-01-22_summary.md
Normal file
37
docs/2026-01-22_summary.md
Normal 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` 的整天包含语义),全绿
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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='出货日期'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='位置')
|
||||||
|
|||||||
@@ -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: 曲线。
|
||||||
|
|||||||
Reference in New Issue
Block a user