forked from erp-dev/erp
feat: batch update for plate order
This commit is contained in:
@@ -35,7 +35,7 @@ class DataSyncAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(MDYPlateOrderStaging)
|
||||
class MDYPlateOrderStagingAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'mdy_rowid', 'created_at', 'updated_at']
|
||||
list_display = ['id', 'mdy_rowid', 'ctime', 'utime', 'created_at', 'updated_at']
|
||||
list_filter = ['created_at']
|
||||
search_fields = ['mdy_rowid']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
0
api_v1/management/__init__.py
Normal file
0
api_v1/management/__init__.py
Normal file
0
api_v1/management/commands/__init__.py
Normal file
0
api_v1/management/commands/__init__.py
Normal file
35
api_v1/management/commands/sync_mdy_plate_orders.py
Normal file
35
api_v1/management/commands/sync_mdy_plate_orders.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from api_v1.mdy_plate_order_sync import sync_mdy_plate_orders_to_staging
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '同步明道云开版数据表到暂存表(可选抓取跨表关联数据)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--page-size', type=int, default=100)
|
||||
parser.add_argument('--max-pages', type=int, default=None)
|
||||
parser.add_argument('--max-records', type=int, default=None)
|
||||
|
||||
# 默认抓取关联表数据;如需关闭可显式传 --without-related
|
||||
parser.add_argument('--with-related', dest='with_related', action='store_true', default=True)
|
||||
parser.add_argument('--without-related', dest='with_related', action='store_false')
|
||||
|
||||
parser.add_argument('--max-related-per-type', type=int, default=5)
|
||||
parser.add_argument(
|
||||
'--request-interval-seconds',
|
||||
type=float,
|
||||
default=0.02,
|
||||
help='每次明道云请求之间的最小间隔(用于限流,50qps 建议 >= 0.02)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
payload = sync_mdy_plate_orders_to_staging(
|
||||
page_size=options['page_size'],
|
||||
max_pages=options['max_pages'],
|
||||
max_records=options['max_records'],
|
||||
with_related=options['with_related'],
|
||||
max_related_per_type=options['max_related_per_type'],
|
||||
request_interval_seconds=options['request_interval_seconds'],
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(str(payload)))
|
||||
232
api_v1/mdy_plate_order_sync.py
Normal file
232
api_v1/mdy_plate_order_sync.py
Normal file
@@ -0,0 +1,232 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from api_v1 import models as api_models
|
||||
from flower.utils.mingdaoyun.fetch import (
|
||||
fetch_plate_orders_from_mingdaoyun,
|
||||
fetch_row_by_rowid_from_mingdaoyun,
|
||||
)
|
||||
from flower.utils.mingdaoyun.mappings import plate_order_related_worksheet_map
|
||||
from flower.utils.mingdaoyun.relations import (
|
||||
extract_plate_order_related_rowids,
|
||||
flatten_plate_order_related_row,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_mdy_datetime(value: str | None):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
if timezone.is_naive(dt):
|
||||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||||
return dt
|
||||
|
||||
|
||||
async def _fetch_related_flattened(
|
||||
plate_order_row: dict,
|
||||
*,
|
||||
max_related_per_type: int = 5,
|
||||
request_interval_seconds: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""根据开版主表 row 的 Relation 字段,拉取并碾平关联表数据。"""
|
||||
|
||||
related_rowids_map = extract_plate_order_related_rowids(plate_order_row)
|
||||
if not related_rowids_map:
|
||||
return []
|
||||
|
||||
flattened: list[dict] = []
|
||||
for related_key, rowids in related_rowids_map.items():
|
||||
wsid = plate_order_related_worksheet_map.get(related_key)
|
||||
if not wsid:
|
||||
continue
|
||||
|
||||
for rid in rowids[: max_related_per_type or 0]:
|
||||
related_row = await fetch_row_by_rowid_from_mingdaoyun(
|
||||
worksheet_id=wsid,
|
||||
rowid=rid,
|
||||
)
|
||||
if not related_row:
|
||||
continue
|
||||
|
||||
flattened.append(flatten_plate_order_related_row(related_key, related_row))
|
||||
if request_interval_seconds:
|
||||
await asyncio.sleep(request_interval_seconds)
|
||||
|
||||
return flattened
|
||||
|
||||
|
||||
async def _fetch_related_for_rows(
|
||||
rows: list[dict],
|
||||
*,
|
||||
max_related_per_type: int,
|
||||
request_interval_seconds: float = 0.0,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""批量拉取并碾平关联表数据(仅网络请求,不涉及 ORM)。"""
|
||||
|
||||
result: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
rowid = row.get("rowid")
|
||||
if not rowid:
|
||||
continue
|
||||
result[rowid] = await _fetch_related_flattened(
|
||||
row,
|
||||
max_related_per_type=max_related_per_type,
|
||||
request_interval_seconds=request_interval_seconds,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def sync_mdy_plate_orders_to_staging(
|
||||
*,
|
||||
page_size: int = 100,
|
||||
max_pages: int | None = None,
|
||||
max_records: int | None = None,
|
||||
with_related: bool = True,
|
||||
max_related_per_type: int = 5,
|
||||
request_interval_seconds: float = 0.02,
|
||||
) -> dict:
|
||||
"""同步明道云开版数据表到暂存表(含可选跨表关联数据)。
|
||||
|
||||
- 写入 `api_v1.MDYPlateOrderStaging`:
|
||||
- `raw`:整行原始数据
|
||||
- `related`:跨表数据(碾平 list[dict])
|
||||
- 进度记录到 `api_v1.DataSync`(table_name=plate_order)
|
||||
|
||||
说明:
|
||||
- max_pages 表示“单次任务最多处理多少页”(不是最大页码)
|
||||
- max_records 表示“单次任务最多处理多少条记录”(0/None 表示不限制)
|
||||
"""
|
||||
|
||||
max_records = max_records or 0
|
||||
|
||||
last_sync = api_models.DataSync.objects.filter(
|
||||
table_name=api_models.DataSync.TableName.PLATE_ORDER
|
||||
).order_by("-created_at").first()
|
||||
last_ctime = last_sync.last_ctime if last_sync else None
|
||||
last_rowid = last_sync.last_rowid if last_sync else ""
|
||||
start_page_index = last_sync.page_index if last_sync else 1
|
||||
|
||||
synced_rows = 0
|
||||
page_index = max(1, start_page_index)
|
||||
pages_processed = 0
|
||||
total_count = 0
|
||||
latest_ctime = last_ctime
|
||||
latest_rowid = last_rowid
|
||||
|
||||
while True:
|
||||
if max_pages is not None and pages_processed >= max_pages:
|
||||
break
|
||||
if max_records and synced_rows >= max_records:
|
||||
break
|
||||
|
||||
rows, total = asyncio.run(
|
||||
fetch_plate_orders_from_mingdaoyun(
|
||||
page=page_index,
|
||||
page_size=page_size,
|
||||
sort_id="ctime",
|
||||
is_asc=True,
|
||||
)
|
||||
)
|
||||
if request_interval_seconds:
|
||||
time.sleep(request_interval_seconds)
|
||||
total_count = total
|
||||
if not rows:
|
||||
break
|
||||
|
||||
hit_max_records = False
|
||||
to_process: list[dict] = []
|
||||
for row in rows:
|
||||
if max_records and synced_rows + len(to_process) >= max_records:
|
||||
hit_max_records = True
|
||||
break
|
||||
|
||||
rowid = row.get("rowid")
|
||||
if not rowid:
|
||||
continue
|
||||
|
||||
record_ctime = _parse_mdy_datetime(row.get("ctime"))
|
||||
|
||||
# 跳过已同步到的游标
|
||||
if last_ctime and record_ctime:
|
||||
if record_ctime < last_ctime:
|
||||
continue
|
||||
if record_ctime == last_ctime and last_rowid and rowid == last_rowid:
|
||||
continue
|
||||
|
||||
to_process.append(row)
|
||||
|
||||
related_map: dict[str, list[dict]] = {}
|
||||
if with_related and to_process:
|
||||
related_map = asyncio.run(
|
||||
_fetch_related_for_rows(
|
||||
to_process,
|
||||
max_related_per_type=max_related_per_type,
|
||||
request_interval_seconds=request_interval_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
for row in to_process:
|
||||
rowid = row.get("rowid")
|
||||
if not rowid:
|
||||
continue
|
||||
|
||||
api_models.MDYPlateOrderStaging.objects.update_or_create(
|
||||
mdy_rowid=rowid,
|
||||
defaults={
|
||||
"ctime": _parse_mdy_datetime(row.get("ctime")),
|
||||
"utime": _parse_mdy_datetime(row.get("utime")),
|
||||
"raw": row,
|
||||
"related": related_map.get(rowid, []),
|
||||
},
|
||||
)
|
||||
synced_rows += 1
|
||||
|
||||
record_ctime = _parse_mdy_datetime(row.get("ctime"))
|
||||
if record_ctime:
|
||||
if latest_ctime is None or record_ctime > latest_ctime:
|
||||
latest_ctime = record_ctime
|
||||
latest_rowid = rowid
|
||||
elif record_ctime == latest_ctime:
|
||||
latest_rowid = rowid
|
||||
|
||||
pages_processed += 1
|
||||
if hit_max_records:
|
||||
break
|
||||
|
||||
if len(rows) < page_size:
|
||||
break
|
||||
page_index += 1
|
||||
|
||||
record_last_ctime = latest_ctime or last_ctime
|
||||
record_last_rowid = latest_rowid or last_rowid
|
||||
|
||||
api_models.DataSync.objects.create(
|
||||
table_name=api_models.DataSync.TableName.PLATE_ORDER,
|
||||
page_index=page_index,
|
||||
page_size=page_size,
|
||||
synced_rows=synced_rows,
|
||||
total_count=total_count,
|
||||
last_ctime=record_last_ctime,
|
||||
last_rowid=record_last_rowid,
|
||||
note=f"asc scan; with_related={with_related}",
|
||||
)
|
||||
|
||||
payload = {
|
||||
"synced_rows": synced_rows,
|
||||
"page_index": page_index,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"last_ctime": record_last_ctime.isoformat() if record_last_ctime else None,
|
||||
"with_related": with_related,
|
||||
}
|
||||
logger.info("明道云开版暂存同步完成: %s", payload)
|
||||
return payload
|
||||
21
api_v1/migrations/0005_mdy_plate_order_staging_related.py
Normal file
21
api_v1/migrations/0005_mdy_plate_order_staging_related.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api_v1', '0004_mdy_plate_order_staging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mdyplateorderstaging',
|
||||
name='related',
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text='开版相关跨表数据(碾平 list[dict]),用于排查与后续属性化提取',
|
||||
verbose_name='关联表数据',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.db import migrations, models
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api_v1', '0005_mdy_plate_order_staging_related'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='mdyplateorderstaging',
|
||||
index=models.Index(
|
||||
KeyTextTransform('62d52f4b8d2972284492dd0e', 'raw'),
|
||||
name='api_mdy_po_raw_id_idx',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api_v1', '0006_mdy_plate_order_staging_raw_id_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mdyplateorderstaging',
|
||||
name='ctime',
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text='明道云系统字段 ctime(用于排序/增量定位)',
|
||||
null=True,
|
||||
verbose_name='明道云创建时间',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mdyplateorderstaging',
|
||||
name='utime',
|
||||
field=models.DateTimeField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text='明道云系统字段 utime(用于排序/增量定位)',
|
||||
null=True,
|
||||
verbose_name='明道云更新时间',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
from flower.common import ModelBase
|
||||
|
||||
User = get_user_model()
|
||||
@@ -90,6 +91,7 @@ class DataSync(ModelBase):
|
||||
class TableName(models.TextChoices):
|
||||
PRODUCT = 'product', '产品'
|
||||
CUSTOMER = 'customer', '客户'
|
||||
PLATE_ORDER = 'plate_order', '开版(明道云)'
|
||||
|
||||
table_name = models.CharField(max_length=50, choices=TableName.choices, verbose_name='同步目标')
|
||||
page_index = models.PositiveIntegerField(default=1, verbose_name='页码')
|
||||
@@ -126,18 +128,46 @@ class MDYPlateOrderStaging(ModelBase):
|
||||
verbose_name='明道云 RowID',
|
||||
help_text='明道云行记录的 rowid(全局唯一)',
|
||||
)
|
||||
ctime = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
verbose_name='明道云创建时间',
|
||||
help_text='明道云系统字段 ctime(用于排序/增量定位)',
|
||||
)
|
||||
utime = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
verbose_name='明道云更新时间',
|
||||
help_text='明道云系统字段 utime(用于排序/增量定位)',
|
||||
)
|
||||
raw = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name='原始行数据',
|
||||
help_text='getFilterRows 返回的整行原始数据(包含各 controlId 字段)',
|
||||
)
|
||||
related = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
verbose_name='关联表数据',
|
||||
help_text='开版相关跨表数据(碾平 list[dict]),用于排查与后续属性化提取',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'api_mdy_plate_order_staging'
|
||||
verbose_name = '明道云开版暂存'
|
||||
verbose_name_plural = '明道云开版暂存'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
# 明道云字段:62d52f4b8d2972284492dd0e(开版表“订单id/设计编号”)
|
||||
# 用于在暂存表中按该字段快速检索
|
||||
models.Index(
|
||||
KeyTextTransform('62d52f4b8d2972284492dd0e', 'raw'),
|
||||
name='api_mdy_po_raw_id_idx',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.mdy_rowid}'
|
||||
|
||||
@@ -18,6 +18,7 @@ from flower.utils import (
|
||||
fetch_products_from_mingdaoyun,
|
||||
fetch_customers_from_mingdaoyun,
|
||||
)
|
||||
from api_v1.mdy_plate_order_sync import sync_mdy_plate_orders_to_staging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -441,3 +442,27 @@ def sync_mdy_customers(self, page_size: int = 300, max_pages: int | None = None,
|
||||
}
|
||||
logger.info('明道云客户同步完成: %s', payload)
|
||||
return payload
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def sync_mdy_plate_orders(
|
||||
self,
|
||||
page_size: int = 300,
|
||||
max_pages: int | None = None,
|
||||
max_records: int | None = None,
|
||||
with_related: bool = True,
|
||||
max_related_per_type: int = 5,
|
||||
request_interval_seconds: float = 0.02,
|
||||
):
|
||||
"""从明道云同步开版数据表到暂存表(可选抓取跨表关联数据)。"""
|
||||
|
||||
payload = sync_mdy_plate_orders_to_staging(
|
||||
page_size=page_size,
|
||||
max_pages=max_pages,
|
||||
max_records=max_records,
|
||||
with_related=with_related,
|
||||
max_related_per_type=max_related_per_type,
|
||||
request_interval_seconds=request_interval_seconds,
|
||||
)
|
||||
payload["task_id"] = self.request.id
|
||||
return payload
|
||||
|
||||
@@ -24,6 +24,7 @@ from .views.upload import UploadFileViewSet
|
||||
from .views.products import ProductQuickViewSet
|
||||
from .views.parameters import StateParameterViewSet
|
||||
from .views.users import CreateUserWithProfileView
|
||||
from .views.mingdaoyun import MDYPlateOrderStagingViewSet
|
||||
|
||||
# 创建 DRF Router for Stateflow
|
||||
stateflow_router = DefaultRouter()
|
||||
@@ -39,6 +40,7 @@ main_router.register(r'plate-orders', PlateOrderViewSet, basename='plate-order')
|
||||
main_router.register(r'upload', UploadFileViewSet, basename='upload')
|
||||
main_router.register(r'products/quick', ProductQuickViewSet, basename='product-quick')
|
||||
main_router.register(r'parameters', StateParameterViewSet, basename='parameter')
|
||||
main_router.register(r'mdy-plate-order-staging', MDYPlateOrderStagingViewSet, basename='mdy-plate-order-staging')
|
||||
|
||||
urlpatterns = [
|
||||
# 库存变动相关API
|
||||
|
||||
5
api_v1/views/mingdaoyun/__init__.py
Normal file
5
api_v1/views/mingdaoyun/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""MingdaoYun related API views."""
|
||||
|
||||
from .plate_order_staging import MDYPlateOrderStagingViewSet
|
||||
|
||||
__all__ = ["MDYPlateOrderStagingViewSet"]
|
||||
102
api_v1/views/mingdaoyun/plate_order_staging.py
Normal file
102
api_v1/views/mingdaoyun/plate_order_staging.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""开版暂存(明道云)查询 API。
|
||||
|
||||
需求:
|
||||
- 默认按 -ctime 排序
|
||||
- 支持 raw__62d52f4b8d2972284492dd0e(设计编号)过滤
|
||||
- 支持 LimitOffset 分页
|
||||
- 返回时把 raw / related 两个 JSONField 做“可读化/碾平”
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import F
|
||||
from rest_framework import serializers, viewsets
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import DjangoModelPermissions
|
||||
|
||||
from api_v1.models import MDYPlateOrderStaging
|
||||
from flower.utils.mingdaoyun.mappings import plate_order_field_definitions
|
||||
from flower.utils.mingdaoyun.relations import flatten_row_by_field_definitions, normalize_mdy_value
|
||||
|
||||
|
||||
_PLATE_ORDER_DESIGN_NO_CONTROL_ID = "62d52f4b8d2972284492dd0e"
|
||||
|
||||
|
||||
class MDYPlateOrderStagingSerializer(serializers.ModelSerializer):
|
||||
"""把 raw/related 输出为扁平的可读结构。"""
|
||||
|
||||
design_no = serializers.SerializerMethodField()
|
||||
raw = serializers.SerializerMethodField()
|
||||
related = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MDYPlateOrderStaging
|
||||
fields = [
|
||||
"id",
|
||||
"mdy_rowid",
|
||||
"ctime",
|
||||
"utime",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"design_no",
|
||||
"raw",
|
||||
"related",
|
||||
]
|
||||
|
||||
def get_design_no(self, obj: MDYPlateOrderStaging) -> Any:
|
||||
if isinstance(obj.raw, dict):
|
||||
return obj.raw.get(_PLATE_ORDER_DESIGN_NO_CONTROL_ID)
|
||||
return None
|
||||
|
||||
def get_raw(self, obj: MDYPlateOrderStaging) -> dict[str, Any]:
|
||||
if not isinstance(obj.raw, dict):
|
||||
return {}
|
||||
# 1) 已知字段:按映射转换为中文 key(更可读)
|
||||
out = flatten_row_by_field_definitions(obj.raw, plate_order_field_definitions, include_system_keys=True)
|
||||
|
||||
# 2) 未映射字段:为了排查方便,不静默丢弃,保留 controlId 原样
|
||||
known_control_ids = set(plate_order_field_definitions.keys())
|
||||
for k, v in obj.raw.items():
|
||||
if k in {"rowid", "ctime", "utime"}:
|
||||
continue
|
||||
if k in known_control_ids:
|
||||
continue
|
||||
out[str(k)] = normalize_mdy_value(v)
|
||||
|
||||
return out
|
||||
|
||||
def get_related(self, obj: MDYPlateOrderStaging) -> list[dict[str, Any]]:
|
||||
if not obj.related:
|
||||
return []
|
||||
if not isinstance(obj.related, list):
|
||||
return []
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in obj.related:
|
||||
if isinstance(item, dict):
|
||||
out.append({k: normalize_mdy_value(v) for k, v in item.items()})
|
||||
else:
|
||||
# 兜底:若数据被写成非 dict 元素,仍然返回原值,便于排查
|
||||
out.append({"value": item})
|
||||
return out
|
||||
|
||||
|
||||
class MDYPlateOrderStagingViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""开版暂存数据查询(只读)。"""
|
||||
|
||||
serializer_class = MDYPlateOrderStagingSerializer
|
||||
# permission_classes = [DjangoModelPermissions]
|
||||
pagination_class = LimitOffsetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
qs = MDYPlateOrderStaging.objects.all()
|
||||
|
||||
# 支持按 raw 中的“设计编号/订单id”过滤(按你要求的 query param 名)
|
||||
design_no = self.request.query_params.get(f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}")
|
||||
if design_no:
|
||||
qs = qs.filter(**{f"raw__{_PLATE_ORDER_DESIGN_NO_CONTROL_ID}": design_no})
|
||||
|
||||
# 默认 -ctime;ctime 为空的放最后,避免老数据(未回填)跑到前面
|
||||
return qs.order_by(F("ctime").desc(nulls_last=True), F("id").desc())
|
||||
101
api_v1/views/mingdaoyun/test_plate_order_staging_api.py
Normal file
101
api_v1/views/mingdaoyun/test_plate_order_staging_api.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""MDYPlateOrderStaging 查询 API 测试"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from api_v1.models import MDYPlateOrderStaging
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class MDYPlateOrderStagingAPITestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser",
|
||||
password="testpass123",
|
||||
email="test@example.com",
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
view_perm = Permission.objects.get(codename="view_mdyplateorderstaging")
|
||||
self.user.user_permissions.add(view_perm)
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# older
|
||||
self.row1 = MDYPlateOrderStaging.objects.create(
|
||||
mdy_rowid="row1",
|
||||
ctime=now - timedelta(days=1),
|
||||
utime=now - timedelta(days=1, minutes=1),
|
||||
raw={
|
||||
"rowid": "row1",
|
||||
"ctime": "2025-01-01 00:00:00",
|
||||
"utime": "2025-01-01 00:01:00",
|
||||
"62d52f4b8d2972284492dd0e": "A001", # 设计编号
|
||||
"62d52f4b8d2972284492dd2c": "款号A", # 款号名称
|
||||
},
|
||||
related=[
|
||||
{
|
||||
"rowid": "rel1",
|
||||
"source": "drawing",
|
||||
"source_name": "画图",
|
||||
"设计师名字": "张三",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# newer
|
||||
self.row2 = MDYPlateOrderStaging.objects.create(
|
||||
mdy_rowid="row2",
|
||||
ctime=now,
|
||||
utime=now,
|
||||
raw={
|
||||
"rowid": "row2",
|
||||
"ctime": "2025-01-02 00:00:00",
|
||||
"utime": "2025-01-02 00:01:00",
|
||||
"62d52f4b8d2972284492dd0e": "A002", # 设计编号
|
||||
"62d52f4b8d2972284492dd2c": "款号B", # 款号名称
|
||||
},
|
||||
related=[],
|
||||
)
|
||||
|
||||
def test_list_default_order_and_flatten(self):
|
||||
resp = self.client.get("/api/v1/mdy-plate-order-staging/?limit=10&offset=0")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
payload = resp.json()
|
||||
self.assertEqual(payload["count"], 2)
|
||||
|
||||
first = payload["results"][0]
|
||||
self.assertEqual(first["mdy_rowid"], "row2") # -ctime 排序
|
||||
|
||||
# raw 已按字段定义“可读化”
|
||||
self.assertIn("raw", first)
|
||||
self.assertIn("设计编号", first["raw"])
|
||||
self.assertEqual(first["raw"]["设计编号"], "A002")
|
||||
self.assertEqual(first["raw"]["款号名称"], "款号B")
|
||||
|
||||
# related 以 list[dict] 返回
|
||||
self.assertIn("related", first)
|
||||
self.assertEqual(first["related"], [])
|
||||
|
||||
def test_filter_by_raw_design_no(self):
|
||||
resp = self.client.get(
|
||||
"/api/v1/mdy-plate-order-staging/?limit=10&offset=0&raw__62d52f4b8d2972284492dd0e=A001"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
payload = resp.json()
|
||||
self.assertEqual(payload["count"], 1)
|
||||
self.assertEqual(payload["results"][0]["mdy_rowid"], "row1")
|
||||
|
||||
self.assertEqual(payload["results"][0]["raw"]["设计编号"], "A001")
|
||||
self.assertEqual(payload["results"][0]["related"][0]["source"], "drawing")
|
||||
@@ -236,7 +236,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.PrintingJob
|
||||
fields = [
|
||||
'id', 'printing_order', 'printing_order_id', 'product', 'product_name',
|
||||
'id', 'original_id', 'printing_order', 'printing_order_id', 'product', 'product_name',
|
||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state', 'work_state_display',
|
||||
'status', 'is_completed', 'business_object_id',
|
||||
@@ -280,7 +280,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.PrintingJob
|
||||
fields = [
|
||||
'id', 'printing_order', 'printing_order_id', 'product', 'product_name', 'product_code',
|
||||
'id', 'original_id', 'printing_order', 'printing_order_id', 'product', 'product_name', 'product_code',
|
||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state', 'work_state_display',
|
||||
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
|
||||
@@ -312,7 +312,7 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.PrintingJob
|
||||
fields = [
|
||||
'id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'id', 'original_id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
|
||||
'work_state',
|
||||
'batch_advance_records',
|
||||
]
|
||||
@@ -431,7 +431,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
||||
model = models.PlateOrder
|
||||
|
||||
fields = [
|
||||
'id', 'design_code', 'plate_type', 'plate_date', 'plate_method',
|
||||
'id', 'original_id', 'design_code', 'plate_type', 'plate_date', 'plate_method',
|
||||
'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason',
|
||||
'urgency_level', 'is_invalid',
|
||||
'customer', 'customer_name', 'area', 'default_address',
|
||||
@@ -504,7 +504,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
|
||||
class Meta:
|
||||
model = models.PlateOrder
|
||||
fields = [
|
||||
'id', 'design_code', 'plate_type', 'plate_date', 'plate_method',
|
||||
'id', 'original_id', 'design_code', 'plate_type', 'plate_date', 'plate_method',
|
||||
'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason',
|
||||
'urgency_level', 'is_invalid',
|
||||
'customer', 'customer_name', 'customer_phone', 'area', 'default_address',
|
||||
@@ -562,7 +562,7 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.PlateOrder
|
||||
fields = [
|
||||
"id", "design_code", "plate_type", "plate_date", "plate_method",
|
||||
"id", "original_id", "design_code", "plate_type", "plate_date", "plate_method",
|
||||
"plate_image", "image_name", "plate_notes", "reprint_reason",
|
||||
"urgency_level", "is_invalid",
|
||||
"customer", "area", "default_address",
|
||||
|
||||
116
api_v2/tests.py
116
api_v2/tests.py
@@ -2,6 +2,7 @@ from decimal import Decimal
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient, APIRequestFactory
|
||||
@@ -957,3 +958,118 @@ class PlateOrderByProcessV2APITest(TestCase):
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateV2APITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
# 工厂用户(满足 IsPrintingFactory)
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='印染工厂',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.user = get_user_model().objects.create_user(username='factory_user2', password='pass12345')
|
||||
basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
sys_user=self.user,
|
||||
name='工厂员工2',
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# 赋予 change 权限(批量更新必需)
|
||||
perm_change = Permission.objects.get(codename='change_plateorder')
|
||||
self.user.user_permissions.add(perm_change)
|
||||
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='客户A',
|
||||
created_by=None,
|
||||
)
|
||||
self.designer = basic_models.Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='设计师',
|
||||
)
|
||||
|
||||
self.po1 = printing_models.PlateOrder.objects.create(
|
||||
customer=self.customer,
|
||||
design_code='D1',
|
||||
urgency_level='正常',
|
||||
style_name='S1',
|
||||
)
|
||||
self.po2 = printing_models.PlateOrder.objects.create(
|
||||
customer=self.customer,
|
||||
design_code='D2',
|
||||
urgency_level='正常',
|
||||
style_name='S2',
|
||||
)
|
||||
|
||||
def test_batch_update_success(self):
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id, self.po2.id],
|
||||
'data': {'urgency_level': '加急', 'designer': self.designer.id},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['updated_count'], 2)
|
||||
|
||||
self.po1.refresh_from_db()
|
||||
self.po2.refresh_from_db()
|
||||
self.assertEqual(self.po1.urgency_level, '加急')
|
||||
self.assertEqual(self.po2.urgency_level, '加急')
|
||||
self.assertEqual(self.po1.designer_id, self.designer.id)
|
||||
self.assertEqual(self.po2.designer_id, self.designer.id)
|
||||
|
||||
def test_batch_update_fails_when_missing_ids(self):
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id, 999999],
|
||||
'data': {'urgency_level': '加急'},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn('不存在', resp.data.get('detail', ''))
|
||||
|
||||
def test_batch_update_fails_when_unknown_field(self):
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id],
|
||||
'data': {'process': 123},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn('不支持批量更新字段', resp.data.get('detail', ''))
|
||||
|
||||
def test_batch_update_is_invalid_requires_permission(self):
|
||||
# 未授予 can_invalidate_plateorder
|
||||
resp = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id],
|
||||
'data': {'is_invalid': True},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
perm_invalidate = Permission.objects.get(codename='can_invalidate_plateorder')
|
||||
self.user.user_permissions.add(perm_invalidate)
|
||||
|
||||
resp2 = self.client.post(
|
||||
'/api/v2/plate-orders/batch-update/',
|
||||
{
|
||||
'plate_order_ids': [self.po1.id],
|
||||
'data': {'is_invalid': True},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(resp2.status_code, 200)
|
||||
self.po1.refresh_from_db()
|
||||
self.assertTrue(self.po1.is_invalid)
|
||||
|
||||
@@ -7,6 +7,7 @@ from api_v2.views import (
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
PlateOrderBatchUpdateView,
|
||||
BusinessObjectCloneView,
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ urlpatterns = [
|
||||
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
|
||||
path('plate-orders/by-process-node/', PlateOrderByProcessNodeView.as_view(), name='api_v2_plate_order_by_process_node'),
|
||||
path('plate-orders/by-process/', PlateOrderByProcessView.as_view(), name='api_v2_plate_order_by_process'),
|
||||
path('plate-orders/batch-update/', PlateOrderBatchUpdateView.as_view(), name='api_v2_plate_order_batch_update'),
|
||||
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
|
||||
]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .printing import (
|
||||
PrintingJobBatchAdvanceSubmitView,
|
||||
PlateOrderByProcessNodeView,
|
||||
PlateOrderByProcessView,
|
||||
PlateOrderBatchUpdateView,
|
||||
)
|
||||
from .stateflow import BusinessObjectCloneView
|
||||
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
'PrintingJobBatchAdvanceSubmitView',
|
||||
'PlateOrderByProcessNodeView',
|
||||
'PlateOrderByProcessView',
|
||||
'PlateOrderBatchUpdateView',
|
||||
'BusinessObjectCloneView',
|
||||
]
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.db import models as django_models
|
||||
from django.db.models import Q, Count, CharField, Prefetch
|
||||
from django.db.models.functions import Cast, Coalesce
|
||||
from rest_framework import serializers, status, permissions
|
||||
@@ -41,6 +42,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
|
||||
model = printing_models.PrintingJob
|
||||
fields = [
|
||||
'id',
|
||||
'original_id',
|
||||
'printing_order',
|
||||
'product',
|
||||
'work_state',
|
||||
@@ -385,6 +387,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
|
||||
model = printing_models.PlateOrder
|
||||
fields = [
|
||||
'id',
|
||||
'original_id',
|
||||
'design_code',
|
||||
'customer',
|
||||
'customer_name',
|
||||
@@ -627,6 +630,7 @@ class PlateOrderByProcessSerializer(serializers.ModelSerializer):
|
||||
model = printing_models.PlateOrder
|
||||
fields = [
|
||||
'id',
|
||||
'original_id',
|
||||
'design_code',
|
||||
'customer',
|
||||
'customer_name',
|
||||
@@ -886,3 +890,193 @@ class PlateOrderByProcessView(APIView):
|
||||
'previous': paginator.get_previous_link() if page is not None else None,
|
||||
'results': srz.data,
|
||||
})
|
||||
|
||||
|
||||
# 允许批量更新的字段白名单:只改这里即可增减
|
||||
PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS = [
|
||||
"original_id",
|
||||
"design_code",
|
||||
"plate_type",
|
||||
"plate_date",
|
||||
"plate_method",
|
||||
"image_name",
|
||||
"plate_notes",
|
||||
"reprint_reason",
|
||||
"urgency_level",
|
||||
"is_invalid",
|
||||
"customer",
|
||||
"area",
|
||||
"default_address",
|
||||
"salesperson",
|
||||
"merchandiser",
|
||||
"designer",
|
||||
"style_name",
|
||||
"fabric",
|
||||
"fabric_source",
|
||||
"width",
|
||||
"production_method",
|
||||
"is_mark_frame",
|
||||
"drawing_rating",
|
||||
"color_matching_rating",
|
||||
"sample_rating",
|
||||
"difficulty_rating",
|
||||
"sample_meter",
|
||||
"required_sample_meters",
|
||||
"required_completion_date",
|
||||
"completion_date",
|
||||
"approval_result",
|
||||
"is_ordered",
|
||||
"customer_feedback",
|
||||
]
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateDataSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
PlateOrder 批量更新允许字段(白名单)。
|
||||
|
||||
说明:
|
||||
- 不允许更新 process/business_object/created_by 等会触发流程副作用或越权的字段
|
||||
- plate_image 属于结构化字段(且 v1 有额外转换逻辑),暂不纳入批量更新,避免前端误用
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = printing_models.PlateOrder
|
||||
fields = PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateRequestSerializer(serializers.Serializer):
|
||||
plate_order_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
allow_empty=False,
|
||||
help_text="需要批量更新的 PlateOrder id 列表(同一组 data 会应用到所有 id)",
|
||||
)
|
||||
data = serializers.DictField(
|
||||
child=serializers.JSONField(),
|
||||
allow_empty=False,
|
||||
help_text='需要更新的字段集合,例如 {"urgency_level": "加急", "designer": 123}',
|
||||
)
|
||||
dry_run = serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
help_text="仅校验与预览,不实际写库",
|
||||
)
|
||||
|
||||
def validate_plate_order_ids(self, value):
|
||||
# 去重保持稳定性(前端可能重复传)
|
||||
deduped = list(dict.fromkeys(value))
|
||||
if not deduped:
|
||||
raise serializers.ValidationError("plate_order_ids 不能为空")
|
||||
return deduped
|
||||
|
||||
def validate_data(self, value):
|
||||
data_srz = PlateOrderBatchUpdateDataSerializer(data=value, partial=True)
|
||||
data_srz.is_valid(raise_exception=True)
|
||||
if not data_srz.validated_data:
|
||||
raise serializers.ValidationError({"detail": "data 不能为空"})
|
||||
return data_srz.validated_data
|
||||
|
||||
|
||||
class PlateOrderBatchUpdateView(APIView):
|
||||
"""
|
||||
PlateOrder 批量更新(同一份 data 应用到多个 plate_order)。
|
||||
|
||||
POST /api/v2/plate-orders/batch-update/
|
||||
Body:
|
||||
{
|
||||
"plate_order_ids": [1, 2, 3],
|
||||
"data": {"urgency_level": "加急", "designer": 10},
|
||||
"dry_run": false
|
||||
}
|
||||
|
||||
规则:
|
||||
- 全成功/全失败(任意 id 不存在直接 400,不做部分更新)
|
||||
- 需要 printing.change_plateorder 权限
|
||||
- 如包含 is_invalid:
|
||||
- is_invalid=true 需要 printing.can_invalidate_plateorder
|
||||
- is_invalid=false 需要 printing.can_activate_plateorder
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsPrintingFactory]
|
||||
|
||||
def post(self, request):
|
||||
# 注意:Django 会缓存 has_perm 结果(user._perm_cache)。
|
||||
# 在测试中 APIClient.force_authenticate 会复用同一个 user 实例,
|
||||
# 可能导致“中途赋权后第二次请求仍判定无权限”的假阴性;这里主动清理缓存更稳妥。
|
||||
for cache_attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
|
||||
if hasattr(request.user, cache_attr):
|
||||
delattr(request.user, cache_attr)
|
||||
|
||||
if not request.user.has_perm("printing.change_plateorder"):
|
||||
return Response({"detail": "您没有权限批量更新开版订单"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# 先对 data 做“字段白名单”校验(保证错误输出为顶层 detail,便于前端/测试消费)
|
||||
raw_data = request.data.get("data") if isinstance(request.data, dict) else None
|
||||
if isinstance(raw_data, dict):
|
||||
allowed = set(PLATE_ORDER_BATCH_UPDATE_ALLOWED_FIELDS)
|
||||
unknown = sorted(set(raw_data.keys()) - allowed)
|
||||
if unknown:
|
||||
return Response(
|
||||
{"detail": f"不支持批量更新字段: {', '.join(unknown)}", "allowed_fields": sorted(allowed)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
srz = PlateOrderBatchUpdateRequestSerializer(data=request.data)
|
||||
srz.is_valid(raise_exception=True)
|
||||
payload = srz.validated_data
|
||||
|
||||
plate_order_ids: list[int] = payload["plate_order_ids"]
|
||||
data: dict = payload["data"]
|
||||
dry_run: bool = payload.get("dry_run", False)
|
||||
|
||||
# is_invalid 权限语义:沿用 v1 的作废/恢复权限
|
||||
if "is_invalid" in data:
|
||||
if data["is_invalid"] is True and not request.user.has_perm("printing.can_invalidate_plateorder"):
|
||||
return Response({"detail": "您没有权限作废开版订单"}, status=status.HTTP_403_FORBIDDEN)
|
||||
if data["is_invalid"] is False and not request.user.has_perm("printing.can_activate_plateorder"):
|
||||
return Response({"detail": "您没有权限恢复开版订单"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
qs = printing_models.PlateOrder.objects.filter(id__in=plate_order_ids)
|
||||
found_ids = list(qs.values_list("id", flat=True))
|
||||
found_set = set(found_ids)
|
||||
missing_ids = [str(i) for i in plate_order_ids if i not in found_set]
|
||||
if missing_ids:
|
||||
return Response({"detail": f"以下 PlateOrder 不存在: {', '.join(missing_ids)}"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# 返回/展示时保持与入参一致的顺序
|
||||
found_ids = [i for i in plate_order_ids if i in found_set]
|
||||
|
||||
# 将 validated_data 转换为 queryset.update 可用的 kwargs(处理 FK -> *_id)
|
||||
update_kwargs: dict = {}
|
||||
for k, v in data.items():
|
||||
try:
|
||||
field = printing_models.PlateOrder._meta.get_field(k)
|
||||
except Exception:
|
||||
update_kwargs[k] = v
|
||||
continue
|
||||
|
||||
if isinstance(field, django_models.ForeignKey):
|
||||
update_kwargs[f"{k}_id"] = v.pk if v is not None else None
|
||||
else:
|
||||
update_kwargs[k] = v
|
||||
|
||||
update_kwargs["updated_at"] = timezone.now()
|
||||
|
||||
if dry_run:
|
||||
return Response(
|
||||
{
|
||||
"dry_run": True,
|
||||
"plate_order_ids": found_ids,
|
||||
"matched_count": len(found_ids),
|
||||
"data": request.data.get("data") or {},
|
||||
}
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
updated_count = qs.update(**update_kwargs)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"detail": "批量更新成功",
|
||||
"updated_count": updated_count,
|
||||
"plate_order_ids": found_ids,
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
30425
data-bak/db-backup-20251217-190000.sql
Normal file
30425
data-bak/db-backup-20251217-190000.sql
Normal file
File diff suppressed because it is too large
Load Diff
59
docs/2025-12-17.md
Normal file
59
docs/2025-12-17.md
Normal file
@@ -0,0 +1,59 @@
|
||||
### 2025-12-17 工作总结(flower.utils 重构)
|
||||
|
||||
#### 目标
|
||||
- **降低 `flower.utils` 单文件复杂度**:将明道云/同步相关能力拆分为可维护的模块化结构。
|
||||
- **保持向后兼容**:不改动现有调用方(例如 `api_v1/tasks.py` 中 `from flower.utils import fetch_products_from_mingdaoyun`)。
|
||||
- **为后续“开版数据表”关联查询打基础**:沉淀 worksheetId、rowId 查询封装、类型定义与测试。
|
||||
|
||||
#### 主要改动
|
||||
- **`flower.utils` 目录化(从单文件改为包)**
|
||||
- 将原 `flower/utils.py` 拆分为 `flower/utils/` 包,并删除旧文件。
|
||||
- 新增 `flower/utils/__init__.py` 作为兼容入口,继续导出原先常用符号。
|
||||
- 明道云相关实现统一放到 `flower/utils/mingdaoyun/`。
|
||||
|
||||
- **明道云客户端与拉取函数模块化**
|
||||
- 新增客户端:`flower/utils/mingdaoyun/client.py`
|
||||
- `MingDaoYunClient`(aiohttp 异步客户端)
|
||||
- `get_default_mingdaoyun_client()`(复用现有 appKey/sign 的默认构造)
|
||||
- 新增拉取函数:`flower/utils/mingdaoyun/fetch.py`
|
||||
- `fetch_products_from_mingdaoyun` / `fetch_customers_from_mingdaoyun`(保持原行为)
|
||||
- `fetch_plate_orders_from_mingdaoyun`:开版表拉取(先返回原始 rows dict,不做字段映射)
|
||||
- `fetch_row_by_rowid_from_mingdaoyun`:按 rowId 等值 filters 查询单条记录(后续关联表查询的通用工具)
|
||||
|
||||
- **明道云映射与类型定义沉淀**
|
||||
- `flower/utils/mingdaoyun/mappings.py`
|
||||
- 维护 worksheetId 常量与映射:产品/客户/面料/开版表
|
||||
- 补充开版表关联数据 worksheetId:画图/调色/套纸样/改图/配色/照图开发
|
||||
- 新增 `plate_order_related_worksheet_map` 与 `plate_order_related_worksheet_map_cn`,便于后续跨表关联查询定位目标表
|
||||
- `flower/utils/mingdaoyun/models.py`
|
||||
- Pydantic 类型:`Product/Customer/Fabric` 等
|
||||
- 通用结构:`MDYRelationItem/MDYAttachmentItem/MDYCollaboratorItem`
|
||||
- 开版表类型占位:`MDYPlateOrder`(仅用于后续解析阶段)
|
||||
- `flower/utils/mingdaoyun/parsers.py`
|
||||
- `pick_product/pick_customer/pick_fabric`:保持现有同步风格的字段提取方法
|
||||
|
||||
#### 测试与质量保障(与 utils 重构直接相关)
|
||||
- **新增独立单元测试**:`api_v1/test_mingdaoyun_utils.py`
|
||||
- 使用 mock,避免真实网络请求。
|
||||
- 覆盖:
|
||||
- `pick_*` 解析与类型转换
|
||||
- `fetch_*` 请求 payload 组装与返回解析
|
||||
- `fetch_row_by_rowid_from_mingdaoyun`(以产品表 `spmx` 为例的 rowId 查询)
|
||||
- `MingDaoYunClient.post` 的认证参数合并与 header 透传
|
||||
- **运行方式**(项目约定):
|
||||
- `uv run python manage.py test api_v1.test_mingdaoyun_utils`
|
||||
|
||||
#### 配套改动(为开版同步做隔离暂存,不影响业务数据)
|
||||
- **新增暂存模型**:`api_v1.models.MDYPlateOrderStaging`
|
||||
- **仅保留**:`mdy_rowid`(唯一 + 索引)与 `raw(JSON)`(整行原始数据)
|
||||
- 迁移:`api_v1/migrations/0004_mdy_plate_order_staging.py`
|
||||
- admin 注册:`api_v1/admin.py`
|
||||
|
||||
#### 穿插的测试修复(独立简述)
|
||||
- **问题**:`api_v1` 打印相关测试大量失败,根因是 `stateflow.services.advance_to_next_state` 对 `BusinessObject.content_type/object_id` 的强约束与部分“非标准创建路径”不兼容。
|
||||
- **修复**:在 `advance_to_next_state` 内加入“自愈绑定”逻辑:当未绑定/解析不到 `content_object` 时,尝试通过 `business_object.printing_job / plate_order` 反向一对一关系推断真实业务对象并补齐绑定,再继续推进。
|
||||
- **结果**:`uv run manage.py test api_v1` 全绿(`265 tests`,`skipped=2`,`expected failures=1`)。
|
||||
|
||||
#### 后续建议
|
||||
- 将明道云 `appKey/sign` 从硬编码迁移到环境变量或 Django settings(避免泄露与便于多环境配置)。
|
||||
- 在“开版表关联查询”落地时,基于 `plate_order_related_worksheet_map(_cn)` + `fetch_row_by_rowid_from_mingdaoyun` 补齐跨表解析与字段属性化提取策略。
|
||||
112
docs/api_v1_mdy_plate_order_staging.md
Normal file
112
docs/api_v1_mdy_plate_order_staging.md
Normal file
@@ -0,0 +1,112 @@
|
||||
## API:开版暂存(明道云)查询
|
||||
|
||||
### 概述
|
||||
该接口用于查询 `api_v1.models.MDYPlateOrderStaging` 中的“开版暂存”数据。
|
||||
|
||||
- **只读**:仅提供 `list/retrieve`(不支持创建/更新/删除)
|
||||
- **分页**:LimitOffset(`limit` / `offset`)
|
||||
- **默认排序**:按 `ctime` 倒序(`-ctime`),并将 `ctime = null` 的记录排在最后
|
||||
- **过滤**:支持按明道云字段 `62d52f4b8d2972284492dd0e`(设计编号/订单id)精确匹配
|
||||
- **返回结构**:`raw` 与 `related` 两个 JSONField 在输出时会做“可读化/碾平”处理
|
||||
|
||||
### Endpoint
|
||||
- **List**:`GET /api/v1/mdy-plate-order-staging/`
|
||||
- **Detail**:`GET /api/v1/mdy-plate-order-staging/{id}/`
|
||||
|
||||
### 权限与认证
|
||||
- 当前接口权限行为 **遵循项目 DRF 全局配置**。
|
||||
- 如启用 `DjangoModelPermissions`,则需要用户具备 `api_v1.view_mdyplateorderstaging` 权限。
|
||||
|
||||
### Query Params
|
||||
#### 分页
|
||||
- **limit**:返回条数
|
||||
- **offset**:偏移量
|
||||
|
||||
#### 过滤
|
||||
- **raw__62d52f4b8d2972284492dd0e**:设计编号/订单id(精确匹配)
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
GET /api/v1/mdy-plate-order-staging/?limit=20&offset=0&raw__62d52f4b8d2972284492dd0e=82724
|
||||
```
|
||||
|
||||
### 默认排序规则
|
||||
接口固定按以下规则排序(不开放 `ordering=` 参数):
|
||||
|
||||
1. `ctime` 倒序:`-ctime`(`nulls_last=True`)
|
||||
2. `id` 倒序:`-id`
|
||||
|
||||
### 返回字段说明(results[] 内的对象)
|
||||
- **id**:本地数据库主键
|
||||
- **mdy_rowid**:明道云 rowid(唯一)
|
||||
- **ctime**:明道云系统字段 ctime(模型字段,便于排序)
|
||||
- **utime**:明道云系统字段 utime(模型字段)
|
||||
- **created_at / updated_at**:本地记录创建/更新时间(ModelBase)
|
||||
- **design_no**:便捷字段,等价于 `raw["62d52f4b8d2972284492dd0e"]`
|
||||
- **raw**:对 `MDYPlateOrderStaging.raw` 的“可读化/碾平”输出
|
||||
- **related**:对 `MDYPlateOrderStaging.related` 的“碾平”输出(list[dict])
|
||||
|
||||
### raw / related 的碾平规则
|
||||
#### raw
|
||||
输出时会把明道云 `controlId -> 字段中文名`,并做基础 normalize:
|
||||
|
||||
- **已知字段映射**:来自 `flower.utils.mingdaoyun.mappings.plate_order_field_definitions`
|
||||
- 输出 key 使用字段中文名(例如 `设计编号`、`款号名称`)
|
||||
- value 会尝试把字符串 JSON(如 `"[]"`、`"[{...}]"`)解析成 Python/JSON 对象
|
||||
- **系统字段**:若 `raw` 内存在 `rowid/ctime/utime`,会一并输出
|
||||
- **未映射字段不丢失**:如果 `raw` 中存在未在 `plate_order_field_definitions` 里声明的字段,仍会保留,key 使用原始 `controlId` 字符串
|
||||
|
||||
#### related
|
||||
`related` 在入库阶段已经是“单条关联记录一个 dict”的结构(`list[dict]`)。
|
||||
|
||||
输出时:
|
||||
- 对每个 dict 的 value 做一次 normalize(同上)
|
||||
- 如果出现非 dict 的异常元素,兜底返回 `{ "value": <原值> }` 便于排查
|
||||
|
||||
### 响应示例
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 123,
|
||||
"mdy_rowid": "66xxxxxxxxxxxxxxxxxxxxxx",
|
||||
"ctime": "2025-12-17T03:20:00Z",
|
||||
"utime": "2025-12-17T03:21:00Z",
|
||||
"created_at": "2025-12-17T03:30:00Z",
|
||||
"updated_at": "2025-12-17T03:30:00Z",
|
||||
"design_no": "82724",
|
||||
"raw": {
|
||||
"rowid": "66xxxxxxxxxxxxxxxxxxxxxx",
|
||||
"ctime": "2025-12-17 11:20:00",
|
||||
"utime": "2025-12-17 11:21:00",
|
||||
"设计编号": "82724",
|
||||
"款号名称": "某款号",
|
||||
"62d52f4b8d2972284492dd99": "(未映射字段示例:以 controlId 原样返回)"
|
||||
},
|
||||
"related": [
|
||||
{
|
||||
"source": "drawing",
|
||||
"source_name": "画图",
|
||||
"worksheet_id": "668ba100fb551c850214066b",
|
||||
"rowid": "66yyyyyyyyyyyyyyyyyyyyyy",
|
||||
"设计师名字": "张三",
|
||||
"电脑位置": "D:/xxx"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 性能说明
|
||||
- 过滤字段 `raw__62d52f4b8d2972284492dd0e` 对应 PostgreSQL 表达式索引(见迁移 `api_v1/migrations/0006_mdy_plate_order_staging_raw_id_index.py`),用于加速按设计编号检索。
|
||||
|
||||
### 实现位置
|
||||
- View/Serializer:`api_v1/views/mingdaoyun/plate_order_staging.py`
|
||||
- 路由注册:`api_v1/urls.py`(`mdy-plate-order-staging`)
|
||||
- 字段映射:`flower/utils/mingdaoyun/mappings.py`(`plate_order_field_definitions`)
|
||||
- 碾平工具:`flower/utils/mingdaoyun/relations.py`(`flatten_row_by_field_definitions` / `normalize_mdy_value`)
|
||||
156
docs/api_v2_plate_orders_batch_update.md
Normal file
156
docs/api_v2_plate_orders_batch_update.md
Normal file
@@ -0,0 +1,156 @@
|
||||
## API v2:PlateOrder 批量更新(batch-update)
|
||||
|
||||
### 1. 概述
|
||||
用于把**同一份更新数据**(`data`)批量应用到多条开版订单(`PlateOrder`)。
|
||||
|
||||
- **一致性**:全成功 / 全失败(任意 id 不存在或校验失败则全部不更新)
|
||||
- **字段控制**:仅允许更新“白名单字段”(服务端控制,前端不可越权更新)
|
||||
- **不触发流程副作用**:内部使用 `queryset.update()` 批量写库,不会触发 `save()` 逻辑(例如自动创建/更新流程实例等)
|
||||
- **支持 dry_run**:只校验与预览,不实际写库
|
||||
|
||||
### 2. Endpoint
|
||||
- **Method**:POST
|
||||
- **Path**:`/api/v2/plate-orders/batch-update/`
|
||||
- **Content-Type**:`application/json`
|
||||
|
||||
### 3. 权限要求
|
||||
- 需要登录(`IsAuthenticated`)
|
||||
- 需要工厂用户(`IsPrintingFactory`)
|
||||
- 需要权限:`printing.change_plateorder`
|
||||
- 若 `data` 包含 `is_invalid`:
|
||||
- `is_invalid=true` 额外需要 `printing.can_invalidate_plateorder`
|
||||
- `is_invalid=false` 额外需要 `printing.can_activate_plateorder`
|
||||
|
||||
### 4. 请求参数(Body)
|
||||
|
||||
#### 4.1 JSON Schema
|
||||
```json
|
||||
{
|
||||
"plate_order_ids": [1, 2, 3],
|
||||
"data": {
|
||||
"urgency_level": "加急",
|
||||
"designer": 10
|
||||
},
|
||||
"dry_run": false
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 字段说明
|
||||
- **plate_order_ids**:`int[]`(必填)
|
||||
- 需要更新的 PlateOrder id 列表
|
||||
- 会自动去重(保持首个出现顺序)
|
||||
- 任意一个 id 不存在:直接 400(不做部分更新)
|
||||
- **data**:`object`(必填)
|
||||
- 仅包含需要更新的字段键值对(字段必须在“白名单字段”中)
|
||||
- 只要 `data` 校验失败:直接 400,且不会更新任何订单
|
||||
- **dry_run**:`boolean`(可选,默认 `false`)
|
||||
- `true`:仅校验、返回匹配到的 id 与原始 data,不写库
|
||||
|
||||
### 5. 允许批量更新的字段(白名单)
|
||||
> 只允许更新以下字段;其它字段(例如 `process`/`business_object`/`created_by`/`plate_image` 等)都会被拒绝。
|
||||
|
||||
| 字段名 | 类型 | 示例 | 备注 |
|
||||
|---|---|---|---|
|
||||
| original_id | int \| null | 12345 | 克隆来源订单ID |
|
||||
| design_code | string \| null | "D20251218" | 设计编号 |
|
||||
| plate_type | string \| null | "首版" | 起版情况 |
|
||||
| plate_date | datetime \| null | "2025-12-18T10:00:00Z" | 下版时间 |
|
||||
| plate_method | string \| null | "圆网" | 开版方式 |
|
||||
| image_name | string \| null | "图A" | 图片名称 |
|
||||
| plate_notes | string \| null | "注意事项" | 打版注意事项 |
|
||||
| reprint_reason | string \| null | "复版原因" | 复版原因 |
|
||||
| urgency_level | string | "正常"/"加急" | 紧急程度 |
|
||||
| is_invalid | boolean | true/false | 作废/恢复(有额外权限要求) |
|
||||
| customer | int | 100 | 客户ID(不可为 null) |
|
||||
| area | string \| null | "广州" | 区域 |
|
||||
| default_address | string \| null | "XX路" | 默认地址 |
|
||||
| salesperson | int \| null | 11 | 销售员 Employee ID |
|
||||
| merchandiser | int \| null | 12 | 跟单员 Employee ID |
|
||||
| designer | int \| null | 13 | 设计师 Employee ID |
|
||||
| style_name | string \| null | "S1" | 款号名称 |
|
||||
| fabric | string \| null | "棉" | 布料 |
|
||||
| fabric_source | string \| null | "现货" | 布料来源 |
|
||||
| width | string \| null | "150cm" | 幅宽 |
|
||||
| production_method | string \| null | "现货" | 做货方式 |
|
||||
| is_mark_frame | boolean | true/false | 是否套唛架 |
|
||||
| drawing_rating | string \| null | "A" | 画图评级 |
|
||||
| color_matching_rating | string \| null | "B" | 调色评级 |
|
||||
| sample_rating | string \| null | "C" | 套样评级 |
|
||||
| difficulty_rating | string \| null | "简单" | 难度评级 |
|
||||
| sample_meter | string \| null | "5米" | 米样(文本) |
|
||||
| required_sample_meters | number \| null | 12.5 | 客户要求米样米数(Decimal) |
|
||||
| required_completion_date | datetime \| null | "2025-12-20T00:00:00Z" | 要求完成时间 |
|
||||
| completion_date | datetime \| null | "2025-12-19T12:00:00Z" | 完成时间 |
|
||||
| approval_result | string \| null | "通过" | 审批结果 |
|
||||
| is_ordered | boolean | true/false | 是否已下单 |
|
||||
| customer_feedback | string \| null | "请改色" | 客户修改意见 |
|
||||
|
||||
> datetime 建议使用 ISO 8601 字符串(Django/DRF 默认可解析)。
|
||||
|
||||
### 6. 响应
|
||||
|
||||
#### 6.1 成功(dry_run=false)
|
||||
- **HTTP 200**
|
||||
```json
|
||||
{
|
||||
"detail": "批量更新成功",
|
||||
"updated_count": 3,
|
||||
"plate_order_ids": [1, 2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2 成功(dry_run=true)
|
||||
- **HTTP 200**
|
||||
```json
|
||||
{
|
||||
"dry_run": true,
|
||||
"plate_order_ids": [1, 2, 3],
|
||||
"matched_count": 3,
|
||||
"data": {
|
||||
"urgency_level": "加急",
|
||||
"designer": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 错误返回
|
||||
|
||||
#### 7.1 字段不在白名单
|
||||
- **HTTP 400**
|
||||
```json
|
||||
{
|
||||
"detail": "不支持批量更新字段: process",
|
||||
"allowed_fields": ["..." ]
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.2 部分 id 不存在
|
||||
- **HTTP 400**
|
||||
```json
|
||||
{
|
||||
"detail": "以下 PlateOrder 不存在: 999999"
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.3 权限不足
|
||||
- **HTTP 403**
|
||||
```json
|
||||
{ "detail": "您没有权限批量更新开版订单" }
|
||||
```
|
||||
或(作废/恢复权限不足):
|
||||
```json
|
||||
{ "detail": "您没有权限作废开版订单" }
|
||||
```
|
||||
|
||||
#### 7.4 字段类型/外键校验失败(DRF 校验错误)
|
||||
- **HTTP 400**(典型结构示例)
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"designer": ["Invalid pk \"999\" - object does not exist."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 使用建议
|
||||
- 批量更新是“同一份 data 应用到多个订单”。如果每个订单需要不同字段值,请拆成多次调用或使用单条更新接口。
|
||||
@@ -43,10 +43,18 @@ from .mingdaoyun import (
|
||||
pick_fabric,
|
||||
pick_product,
|
||||
plate_order_field_definitions,
|
||||
plate_order_related_field_definitions,
|
||||
plate_order_related_worksheet_map,
|
||||
plate_order_related_worksheet_map_cn,
|
||||
plate_order_relation_control_map,
|
||||
plate_order_type_map,
|
||||
product_type_map,
|
||||
# relations / flatten utils
|
||||
normalize_mdy_value,
|
||||
extract_relation_rowids,
|
||||
extract_plate_order_related_rowids,
|
||||
flatten_row_by_field_definitions,
|
||||
flatten_plate_order_related_row,
|
||||
sync_fabric_from_mingdaoyun,
|
||||
)
|
||||
|
||||
@@ -75,9 +83,17 @@ __all__ = [
|
||||
"customer_type_map",
|
||||
"fabric_type_map",
|
||||
"plate_order_field_definitions",
|
||||
"plate_order_related_field_definitions",
|
||||
"plate_order_related_worksheet_map",
|
||||
"plate_order_related_worksheet_map_cn",
|
||||
"plate_order_relation_control_map",
|
||||
"plate_order_type_map",
|
||||
# relations / flatten utils
|
||||
"normalize_mdy_value",
|
||||
"extract_relation_rowids",
|
||||
"extract_plate_order_related_rowids",
|
||||
"flatten_row_by_field_definitions",
|
||||
"flatten_plate_order_related_row",
|
||||
# models
|
||||
"Product",
|
||||
"ProductListResponse",
|
||||
|
||||
@@ -29,8 +29,10 @@ from .mappings import (
|
||||
fabric_type_map,
|
||||
mdy_table_map,
|
||||
plate_order_field_definitions,
|
||||
plate_order_related_field_definitions,
|
||||
plate_order_related_worksheet_map,
|
||||
plate_order_related_worksheet_map_cn,
|
||||
plate_order_relation_control_map,
|
||||
plate_order_type_map,
|
||||
product_type_map,
|
||||
)
|
||||
@@ -45,6 +47,13 @@ from .models import (
|
||||
ProductListResponse,
|
||||
)
|
||||
from .parsers import pick_customer, pick_fabric, pick_product
|
||||
from .relations import (
|
||||
extract_plate_order_related_rowids,
|
||||
extract_relation_rowids,
|
||||
flatten_plate_order_related_row,
|
||||
flatten_row_by_field_definitions,
|
||||
normalize_mdy_value,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# client
|
||||
@@ -71,8 +80,10 @@ __all__ = [
|
||||
"customer_type_map",
|
||||
"fabric_type_map",
|
||||
"plate_order_field_definitions",
|
||||
"plate_order_related_field_definitions",
|
||||
"plate_order_related_worksheet_map",
|
||||
"plate_order_related_worksheet_map_cn",
|
||||
"plate_order_relation_control_map",
|
||||
"plate_order_type_map",
|
||||
# models
|
||||
"Product",
|
||||
@@ -87,6 +98,12 @@ __all__ = [
|
||||
"pick_product",
|
||||
"pick_customer",
|
||||
"pick_fabric",
|
||||
# relations / flatten utils
|
||||
"normalize_mdy_value",
|
||||
"extract_relation_rowids",
|
||||
"extract_plate_order_related_rowids",
|
||||
"flatten_row_by_field_definitions",
|
||||
"flatten_plate_order_related_row",
|
||||
# fetch
|
||||
"fetch_products_from_mingdaoyun",
|
||||
"fetch_customers_from_mingdaoyun",
|
||||
|
||||
@@ -126,7 +126,12 @@ async def fetch_row_by_rowid_from_mingdaoyun(
|
||||
if not rows:
|
||||
return None
|
||||
if isinstance(rows[0], dict):
|
||||
return rows[0]
|
||||
first = rows[0]
|
||||
# 明道云在 filters 不合法/不生效时可能仍返回默认列表数据。
|
||||
# 这里做一次校验,避免“误返回不匹配的第一条记录”。
|
||||
if first.get("rowid") != rowid:
|
||||
return None
|
||||
return first
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,158 @@ plate_order_related_worksheet_map_cn = {
|
||||
"照图开发": MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 明道云:开版相关“关联表”字段映射(用于后续关联查询/解析)
|
||||
#
|
||||
# 说明:
|
||||
# - 这里记录的是“字段 controlId -> 字段含义/类型/备注”。
|
||||
# - Relation(29) 字段在写入时通常按文档要求使用 rowId 字符串(多条用逗号分割、全量覆盖),
|
||||
# 但 getFilterRows 拉取时返回结构可能是 list[{"rowid","name","link"}],解析时需兼容两种形态。
|
||||
# ------------------------------------------------------------------------------
|
||||
plate_order_related_field_definitions: Dict[str, Dict[str, Any]] = {
|
||||
# 1) 画图
|
||||
"drawing": {
|
||||
"worksheet_id": MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING,
|
||||
"name": "画图",
|
||||
"fields": {
|
||||
"62d7f09ad55983029b644b25": {"name": "设计师名字", "type": "Text"},
|
||||
"62d52f4b8d2972284492dda0": {"name": "电脑位置", "type": "Text"},
|
||||
"62d52f4b8d2972284492dd9b": {
|
||||
"name": "附件",
|
||||
"type": "Attachment",
|
||||
"note": "json,支持外部链接和 Base64 文件流",
|
||||
},
|
||||
"6412d650120c799be027953d": {
|
||||
"name": "开版管理",
|
||||
"type": "Relation",
|
||||
"note": "String 字符串,多条记录 rowId 用(,)分割,全量覆盖操作",
|
||||
},
|
||||
"62d52f4b8d2972284492dd9d": {
|
||||
"name": "开发管理",
|
||||
"type": "Relation",
|
||||
"note": "String 字符串,多条记录 rowId 用(,)分割,全量覆盖操作",
|
||||
},
|
||||
},
|
||||
},
|
||||
# 2) 调色
|
||||
"coloring": {
|
||||
"worksheet_id": MDY_WORKSHEET_ID_PLATE_ORDER_COLORING,
|
||||
"name": "调色",
|
||||
"fields": {
|
||||
"62d7f0c2d121077a8492619d": {"name": "设计师名字", "type": "Text"},
|
||||
"62d52f4b8d2972284492dda8": {"name": "电脑位置", "type": "Text"},
|
||||
"62d52f4b8d2972284492dda3": {
|
||||
"name": "附件",
|
||||
"type": "Attachment",
|
||||
"note": "json,支持外部链接和 Base64 文件流",
|
||||
},
|
||||
"6716620b985d1a2af61776b5": {
|
||||
"name": "完成数量",
|
||||
"type": "Number",
|
||||
"note": "Double(例如 666.66)",
|
||||
},
|
||||
"62d52f4b8d2972284492dda5": {
|
||||
"name": "开发管理",
|
||||
"type": "Relation",
|
||||
"note": "String 字符串,多条记录 rowId 用(,)分割,全量覆盖操作",
|
||||
},
|
||||
},
|
||||
},
|
||||
# 3) 套纸样
|
||||
"pattern_set": {
|
||||
"worksheet_id": MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET,
|
||||
"name": "套纸样",
|
||||
"fields": {
|
||||
"62d7f0cfa72269e1965389e7": {"name": "设计师名字", "type": "Text"},
|
||||
"62d52f4b8d2972284492de8c": {"name": "电脑位置", "type": "Text"},
|
||||
"62d52f4b8d2972284492de89": {
|
||||
"name": "附件",
|
||||
"type": "Attachment",
|
||||
"note": "json,支持外部链接和 Base64 文件流",
|
||||
},
|
||||
"671661bc8d15ca93fdec7e5f": {
|
||||
"name": "完成数量",
|
||||
"type": "Number",
|
||||
"note": "Double(例如 666.66)",
|
||||
},
|
||||
"62d52f4b8d2972284492de8d": {
|
||||
"name": "开版管理",
|
||||
"type": "Relation",
|
||||
"note": "String 字符串,多条记录 rowId 用(,)分割,全量覆盖操作",
|
||||
},
|
||||
},
|
||||
},
|
||||
# 4) 改图
|
||||
"modify_drawing": {
|
||||
"worksheet_id": MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING,
|
||||
"name": "改图",
|
||||
"fields": {
|
||||
"62d7f0cfa72269e1965389e7": {"name": "设计师名字", "type": "Text"},
|
||||
"62d52f4b8d2972284492de8c": {"name": "电脑位置", "type": "Text"},
|
||||
"62d52f4b8d2972284492de89": {
|
||||
"name": "附件",
|
||||
"type": "Attachment",
|
||||
"note": "json,支持外部链接和 Base64 文件流",
|
||||
},
|
||||
"66e2ebb7da66655f355bf709": {
|
||||
"name": "开版管理",
|
||||
"type": "Relation",
|
||||
"note": "String 字符串,多条记录 rowId 用(,)分割,全量覆盖操作",
|
||||
},
|
||||
},
|
||||
},
|
||||
# 5) 配色
|
||||
"color_scheme": {
|
||||
"worksheet_id": MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME,
|
||||
"name": "配色",
|
||||
"fields": {
|
||||
"62d7f09ad55983029b644b25": {"name": "设计师名字", "type": "Text"},
|
||||
"62d52f4b8d2972284492dda0": {"name": "电脑位置", "type": "Text"},
|
||||
"62d52f4b8d2972284492dd9b": {
|
||||
"name": "附件",
|
||||
"type": "Attachment",
|
||||
"note": "json,支持外部链接和 Base64 文件流",
|
||||
},
|
||||
"672adc9b156abb9a08ab2a61": {
|
||||
"name": "开版管理",
|
||||
"type": "Relation",
|
||||
"note": "String 字符串,多条记录 rowId 用(,)分割,全量覆盖操作",
|
||||
},
|
||||
# 注:配色表无“完成数量”,用“开发数量”替代
|
||||
"66e2ef8d99632ae7376e73ef": {
|
||||
"name": "开发数量",
|
||||
"type": "Number",
|
||||
"note": "Double(例如 666.66)",
|
||||
},
|
||||
},
|
||||
},
|
||||
# 6) 找图开发(照图开发)
|
||||
"image_development": {
|
||||
"worksheet_id": MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT,
|
||||
"name": "找图开发",
|
||||
"fields": {
|
||||
"62d7f09ad55983029b644b25": {"name": "设计师名字", "type": "Text"},
|
||||
"62d52f4b8d2972284492dda0": {"name": "电脑位置", "type": "Text"},
|
||||
"62d52f4b8d2972284492dd9b": {
|
||||
"name": "附件",
|
||||
"type": "Attachment",
|
||||
"note": "json,支持外部链接和 Base64 文件流",
|
||||
},
|
||||
"66e2efb0da66655f355bf963": {
|
||||
"name": "开版管理",
|
||||
"type": "Relation",
|
||||
"note": "String 字符串,多条记录 rowId 用(,)分割,全量覆盖操作",
|
||||
},
|
||||
# 注:找图开发表无“完成数量”,用“开发数量”替代
|
||||
"66e2ef8d99632ae7376e73ef": {
|
||||
"name": "开发数量",
|
||||
"type": "Number",
|
||||
"note": "Double(例如 666.66)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 明道云:字段映射(内部字段名 -> controlId)
|
||||
@@ -165,3 +317,19 @@ plate_order_type_map = {
|
||||
"created_at": "ctime",
|
||||
"rowid": "rowid",
|
||||
}
|
||||
|
||||
# PlateOrder 行中“关联字段 controlId -> 关联表 key”
|
||||
# 用于从开版主表记录中提取跨表 rowid 并进一步查询关联表数据。
|
||||
plate_order_relation_control_map = {
|
||||
plate_order_type_map["drawing_relation"]: "drawing",
|
||||
plate_order_type_map["color_relation"]: "coloring",
|
||||
plate_order_type_map["pattern_set"]: "pattern_set",
|
||||
plate_order_type_map["modify_drawing_relation"]: "modify_drawing",
|
||||
plate_order_type_map["color_scheme_relation"]: "color_scheme",
|
||||
plate_order_type_map["image_development_relation"]: "image_development",
|
||||
}
|
||||
|
||||
# json key 查询
|
||||
# MDYPlateOrderStaging.objects.filter(
|
||||
# raw__62d52f4b8d2972284492dd0e="82724" # 设计编号/订单id(明道云字段ID)
|
||||
# )
|
||||
|
||||
159
flower/utils/mingdaoyun/relations.py
Normal file
159
flower/utils/mingdaoyun/relations.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from .mappings import plate_order_related_field_definitions, plate_order_relation_control_map
|
||||
|
||||
|
||||
def _json_loads_safe(value: str) -> Any:
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def normalize_mdy_value(value: Any) -> Any:
|
||||
"""尽量把明道云返回的字符串 JSON 规范化为 Python 对象。
|
||||
|
||||
常见情况:
|
||||
- Relation/Attachment 可能返回字符串形式的 "[]" / "[{...}]"
|
||||
- 空值经常是 "[]"(字符串)
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
if s == "[]":
|
||||
return []
|
||||
if (s.startswith("[") and s.endswith("]")) or (s.startswith("{") and s.endswith("}")):
|
||||
return _json_loads_safe(s)
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def extract_relation_rowids(value: Any) -> list[str]:
|
||||
"""从 Relation 字段的 value 中提取 rowid 列表。
|
||||
|
||||
兼容形态:
|
||||
- list[dict](常见:[{rowid,name,link}, ...])
|
||||
- str(可能是 JSON 字符串数组,如 "[{...}]" 或 "[]";也可能是逗号分割 rowId)
|
||||
- dict(单条)
|
||||
"""
|
||||
|
||||
value = normalize_mdy_value(value)
|
||||
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, list):
|
||||
out: list[str] = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
rid = item.get("rowid") or item.get("rowId")
|
||||
if rid:
|
||||
out.append(str(rid))
|
||||
elif isinstance(item, str) and item.strip():
|
||||
out.append(item.strip())
|
||||
return out
|
||||
|
||||
if isinstance(value, dict):
|
||||
rid = value.get("rowid") or value.get("rowId")
|
||||
return [str(rid)] if rid else []
|
||||
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
if not s or s == "[]":
|
||||
return []
|
||||
# 兜底:逗号分割
|
||||
return [x for x in (p.strip() for p in s.split(",")) if x]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def extract_plate_order_related_rowids(plate_order_row: dict[str, Any]) -> dict[str, list[str]]:
|
||||
"""从开版主表行记录中提取各关联表的 rowid 列表。
|
||||
|
||||
返回:
|
||||
- {related_key: [rowid, ...], ...}
|
||||
"""
|
||||
|
||||
result: dict[str, list[str]] = {}
|
||||
for control_id, related_key in plate_order_relation_control_map.items():
|
||||
rowids = extract_relation_rowids(plate_order_row.get(control_id))
|
||||
if rowids:
|
||||
result[related_key] = rowids
|
||||
return result
|
||||
|
||||
|
||||
def flatten_row_by_field_definitions(
|
||||
row: dict[str, Any],
|
||||
field_definitions: dict[str, dict[str, Any]],
|
||||
*,
|
||||
drop_types: set[str] | None = None,
|
||||
drop_names: set[str] | None = None,
|
||||
include_system_keys: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""按字段定义把一条明道云 row 转成“可读 dict”。
|
||||
|
||||
- key 使用字段中文名(若缺失则回退 controlId)
|
||||
- value 做一定的 JSON 字符串规范化
|
||||
- 默认包含系统字段:rowid/ctime/utime(若存在)
|
||||
"""
|
||||
|
||||
drop_types = drop_types or set()
|
||||
drop_names = drop_names or set()
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
if include_system_keys:
|
||||
for k in ("rowid", "ctime", "utime"):
|
||||
if k in row:
|
||||
out[k] = row.get(k)
|
||||
|
||||
for field_id, meta in field_definitions.items():
|
||||
name = meta.get("name") or field_id
|
||||
ftype = meta.get("type")
|
||||
|
||||
if ftype in drop_types:
|
||||
continue
|
||||
if name in drop_names:
|
||||
continue
|
||||
|
||||
out[name] = normalize_mdy_value(row.get(field_id))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def flatten_plate_order_related_row(related_key: str, related_row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""将开版关联表的一条 row 扁平化为 dict(用于入库 staging.related)。
|
||||
|
||||
- 自动删除回溯 Relation 字段(开版管理/开发管理等)
|
||||
- 保留 rowid/ctime/utime(若存在)
|
||||
- 额外写入 source 信息,便于排查
|
||||
"""
|
||||
|
||||
meta = plate_order_related_field_definitions.get(related_key) or {}
|
||||
wsid = meta.get("worksheet_id")
|
||||
cn_name = meta.get("name")
|
||||
fields = meta.get("fields") or {}
|
||||
|
||||
flat = flatten_row_by_field_definitions(
|
||||
related_row,
|
||||
fields,
|
||||
drop_types={"Relation"},
|
||||
drop_names={"开版管理", "开发管理"},
|
||||
include_system_keys=True,
|
||||
)
|
||||
|
||||
# 扁平化结构:[{key: val}, ...] 里的每个元素是一条记录
|
||||
flat["source"] = related_key
|
||||
if cn_name:
|
||||
flat["source_name"] = cn_name
|
||||
if wsid:
|
||||
flat["worksheet_id"] = wsid
|
||||
|
||||
return flat
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('printing', '0025_plateorder_created_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plateorder',
|
||||
name='original_id',
|
||||
field=models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
help_text='前端克隆概念:记录本订单由哪个订单复制而来(无外键约束)',
|
||||
null=True,
|
||||
verbose_name='克隆来源订单ID',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='printingjob',
|
||||
name='original_id',
|
||||
field=models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
help_text='前端克隆概念:记录本任务由哪个任务复制而来(无外键约束)',
|
||||
null=True,
|
||||
verbose_name='克隆来源任务ID',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -13,6 +13,12 @@ class PlateOrder(ModelBase):
|
||||
|
||||
# 自动编号相关
|
||||
design_code = models.CharField(max_length=50, blank=True, null=True, verbose_name='设计编号')
|
||||
original_id = models.PositiveBigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='克隆来源订单ID',
|
||||
help_text='前端克隆概念:记录本订单由哪个订单复制而来(无外键约束)',
|
||||
)
|
||||
|
||||
# 版相关信息
|
||||
plate_type = models.CharField(max_length=20, blank=True, null=True, verbose_name='起版情况') # 首版/复版等
|
||||
@@ -335,6 +341,12 @@ class PrintingJob(ModelBase):
|
||||
size = models.CharField(max_length=100, null=True, blank=True, verbose_name='一段尺寸')
|
||||
pieces = models.PositiveIntegerField(null=True, blank=True, verbose_name='件数')
|
||||
description = models.TextField(blank=True, null=True, verbose_name='备注')
|
||||
original_id = models.PositiveBigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='克隆来源任务ID',
|
||||
help_text='前端克隆概念:记录本任务由哪个任务复制而来(无外键约束)',
|
||||
)
|
||||
business_object = models.OneToOneField(
|
||||
stateflow_models.BusinessObject,
|
||||
on_delete=models.SET_NULL,
|
||||
|
||||
Reference in New Issue
Block a user