1
0
forked from erp-dev/erp

feat: batch update for plate order

This commit is contained in:
2025-12-18 17:36:43 +08:00
parent 98d797221a
commit 1a6ce32441
33 changed files with 32087 additions and 8 deletions

View File

@@ -35,7 +35,7 @@ class DataSyncAdmin(admin.ModelAdmin):
@admin.register(MDYPlateOrderStaging) @admin.register(MDYPlateOrderStaging)
class MDYPlateOrderStagingAdmin(admin.ModelAdmin): 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'] list_filter = ['created_at']
search_fields = ['mdy_rowid'] search_fields = ['mdy_rowid']
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']

View File

View File

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

View 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

View 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='关联表数据',
),
),
]

View File

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

View File

@@ -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='明道云更新时间',
),
),
]

View File

@@ -5,6 +5,7 @@ import os
import uuid import uuid
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models.fields.json import KeyTextTransform
from flower.common import ModelBase from flower.common import ModelBase
User = get_user_model() User = get_user_model()
@@ -90,6 +91,7 @@ class DataSync(ModelBase):
class TableName(models.TextChoices): class TableName(models.TextChoices):
PRODUCT = 'product', '产品' PRODUCT = 'product', '产品'
CUSTOMER = 'customer', '客户' CUSTOMER = 'customer', '客户'
PLATE_ORDER = 'plate_order', '开版(明道云)'
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='页码')
@@ -126,18 +128,46 @@ class MDYPlateOrderStaging(ModelBase):
verbose_name='明道云 RowID', verbose_name='明道云 RowID',
help_text='明道云行记录的 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( raw = models.JSONField(
default=dict, default=dict,
blank=True, blank=True,
verbose_name='原始行数据', verbose_name='原始行数据',
help_text='getFilterRows 返回的整行原始数据(包含各 controlId 字段)', help_text='getFilterRows 返回的整行原始数据(包含各 controlId 字段)',
) )
related = models.JSONField(
default=list,
blank=True,
verbose_name='关联表数据',
help_text='开版相关跨表数据(碾平 list[dict]),用于排查与后续属性化提取',
)
class Meta: class Meta:
db_table = 'api_mdy_plate_order_staging' db_table = 'api_mdy_plate_order_staging'
verbose_name = '明道云开版暂存' verbose_name = '明道云开版暂存'
verbose_name_plural = '明道云开版暂存' verbose_name_plural = '明道云开版暂存'
ordering = ['-created_at'] ordering = ['-created_at']
indexes = [
# 明道云字段62d52f4b8d2972284492dd0e开版表“订单id/设计编号”)
# 用于在暂存表中按该字段快速检索
models.Index(
KeyTextTransform('62d52f4b8d2972284492dd0e', 'raw'),
name='api_mdy_po_raw_id_idx',
),
]
def __str__(self): def __str__(self):
return f'{self.mdy_rowid}' return f'{self.mdy_rowid}'

View File

@@ -18,6 +18,7 @@ from flower.utils import (
fetch_products_from_mingdaoyun, fetch_products_from_mingdaoyun,
fetch_customers_from_mingdaoyun, fetch_customers_from_mingdaoyun,
) )
from api_v1.mdy_plate_order_sync import sync_mdy_plate_orders_to_staging
logger = logging.getLogger(__name__) 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) logger.info('明道云客户同步完成: %s', payload)
return 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

View File

@@ -24,6 +24,7 @@ from .views.upload import UploadFileViewSet
from .views.products import ProductQuickViewSet from .views.products import ProductQuickViewSet
from .views.parameters import StateParameterViewSet from .views.parameters import StateParameterViewSet
from .views.users import CreateUserWithProfileView from .views.users import CreateUserWithProfileView
from .views.mingdaoyun import MDYPlateOrderStagingViewSet
# 创建 DRF Router for Stateflow # 创建 DRF Router for Stateflow
stateflow_router = DefaultRouter() 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'upload', UploadFileViewSet, basename='upload')
main_router.register(r'products/quick', ProductQuickViewSet, basename='product-quick') main_router.register(r'products/quick', ProductQuickViewSet, basename='product-quick')
main_router.register(r'parameters', StateParameterViewSet, basename='parameter') main_router.register(r'parameters', StateParameterViewSet, basename='parameter')
main_router.register(r'mdy-plate-order-staging', MDYPlateOrderStagingViewSet, basename='mdy-plate-order-staging')
urlpatterns = [ urlpatterns = [
# 库存变动相关API # 库存变动相关API

View File

@@ -0,0 +1,5 @@
"""MingdaoYun related API views."""
from .plate_order_staging import MDYPlateOrderStagingViewSet
__all__ = ["MDYPlateOrderStagingViewSet"]

View 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})
# 默认 -ctimectime 为空的放最后,避免老数据(未回填)跑到前面
return qs.order_by(F("ctime").desc(nulls_last=True), F("id").desc())

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

View File

@@ -236,7 +236,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = [ 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', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display', 'work_state', 'work_state_display',
'status', 'is_completed', 'business_object_id', 'status', 'is_completed', 'business_object_id',
@@ -280,7 +280,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = [ 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', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state_display', 'work_state', 'work_state_display',
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id', 'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
@@ -312,7 +312,7 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.PrintingJob model = models.PrintingJob
fields = [ fields = [
'id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description', 'id', 'original_id', 'printing_order', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
'work_state', 'work_state',
'batch_advance_records', 'batch_advance_records',
] ]
@@ -431,7 +431,7 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
model = models.PlateOrder model = models.PlateOrder
fields = [ 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', 'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason',
'urgency_level', 'is_invalid', 'urgency_level', 'is_invalid',
'customer', 'customer_name', 'area', 'default_address', 'customer', 'customer_name', 'area', 'default_address',
@@ -504,7 +504,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
class Meta: class Meta:
model = models.PlateOrder model = models.PlateOrder
fields = [ 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', 'plate_image', 'plate_image_url', 'image_name', 'plate_notes', 'reprint_reason',
'urgency_level', 'is_invalid', 'urgency_level', 'is_invalid',
'customer', 'customer_name', 'customer_phone', 'area', 'default_address', 'customer', 'customer_name', 'customer_phone', 'area', 'default_address',
@@ -562,7 +562,7 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.PlateOrder model = models.PlateOrder
fields = [ 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", "plate_image", "image_name", "plate_notes", "reprint_reason",
"urgency_level", "is_invalid", "urgency_level", "is_invalid",
"customer", "area", "default_address", "customer", "area", "default_address",

View File

@@ -2,6 +2,7 @@ from decimal import Decimal
import datetime import datetime
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient, APIRequestFactory from rest_framework.test import APIClient, APIRequestFactory
@@ -957,3 +958,118 @@ class PlateOrderByProcessV2APITest(TestCase):
} }
) )
self.assertEqual(resp.status_code, 400) 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)

View File

@@ -7,6 +7,7 @@ from api_v2.views import (
PrintingJobBatchAdvanceSubmitView, PrintingJobBatchAdvanceSubmitView,
PlateOrderByProcessNodeView, PlateOrderByProcessNodeView,
PlateOrderByProcessView, PlateOrderByProcessView,
PlateOrderBatchUpdateView,
BusinessObjectCloneView, BusinessObjectCloneView,
) )
@@ -17,6 +18,7 @@ urlpatterns = [
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'), 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-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/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'), path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
] ]

View File

@@ -10,6 +10,7 @@ from .printing import (
PrintingJobBatchAdvanceSubmitView, PrintingJobBatchAdvanceSubmitView,
PlateOrderByProcessNodeView, PlateOrderByProcessNodeView,
PlateOrderByProcessView, PlateOrderByProcessView,
PlateOrderBatchUpdateView,
) )
from .stateflow import BusinessObjectCloneView from .stateflow import BusinessObjectCloneView
@@ -21,6 +22,7 @@ __all__ = [
'PrintingJobBatchAdvanceSubmitView', 'PrintingJobBatchAdvanceSubmitView',
'PlateOrderByProcessNodeView', 'PlateOrderByProcessNodeView',
'PlateOrderByProcessView', 'PlateOrderByProcessView',
'PlateOrderBatchUpdateView',
'BusinessObjectCloneView', 'BusinessObjectCloneView',
] ]

View File

@@ -2,6 +2,7 @@ import datetime
from django.utils import timezone from django.utils import timezone
from django.db import transaction 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 import Q, Count, CharField, Prefetch
from django.db.models.functions import Cast, Coalesce from django.db.models.functions import Cast, Coalesce
from rest_framework import serializers, status, permissions from rest_framework import serializers, status, permissions
@@ -41,6 +42,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
model = printing_models.PrintingJob model = printing_models.PrintingJob
fields = [ fields = [
'id', 'id',
'original_id',
'printing_order', 'printing_order',
'product', 'product',
'work_state', 'work_state',
@@ -385,6 +387,7 @@ class PlateOrderByProcessNodeSerializer(serializers.ModelSerializer):
model = printing_models.PlateOrder model = printing_models.PlateOrder
fields = [ fields = [
'id', 'id',
'original_id',
'design_code', 'design_code',
'customer', 'customer',
'customer_name', 'customer_name',
@@ -627,6 +630,7 @@ class PlateOrderByProcessSerializer(serializers.ModelSerializer):
model = printing_models.PlateOrder model = printing_models.PlateOrder
fields = [ fields = [
'id', 'id',
'original_id',
'design_code', 'design_code',
'customer', 'customer',
'customer_name', 'customer_name',
@@ -886,3 +890,193 @@ class PlateOrderByProcessView(APIView):
'previous': paginator.get_previous_link() if page is not None else None, 'previous': paginator.get_previous_link() if page is not None else None,
'results': srz.data, '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.

File diff suppressed because it is too large Load Diff

59
docs/2025-12-17.md Normal file
View 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` 补齐跨表解析与字段属性化提取策略。

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

View File

@@ -0,0 +1,156 @@
## API v2PlateOrder 批量更新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 应用到多个订单”。如果每个订单需要不同字段值,请拆成多次调用或使用单条更新接口。

View File

@@ -43,10 +43,18 @@ from .mingdaoyun import (
pick_fabric, pick_fabric,
pick_product, pick_product,
plate_order_field_definitions, plate_order_field_definitions,
plate_order_related_field_definitions,
plate_order_related_worksheet_map, plate_order_related_worksheet_map,
plate_order_related_worksheet_map_cn, plate_order_related_worksheet_map_cn,
plate_order_relation_control_map,
plate_order_type_map, plate_order_type_map,
product_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, sync_fabric_from_mingdaoyun,
) )
@@ -75,9 +83,17 @@ __all__ = [
"customer_type_map", "customer_type_map",
"fabric_type_map", "fabric_type_map",
"plate_order_field_definitions", "plate_order_field_definitions",
"plate_order_related_field_definitions",
"plate_order_related_worksheet_map", "plate_order_related_worksheet_map",
"plate_order_related_worksheet_map_cn", "plate_order_related_worksheet_map_cn",
"plate_order_relation_control_map",
"plate_order_type_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 # models
"Product", "Product",
"ProductListResponse", "ProductListResponse",

View File

@@ -29,8 +29,10 @@ from .mappings import (
fabric_type_map, fabric_type_map,
mdy_table_map, mdy_table_map,
plate_order_field_definitions, plate_order_field_definitions,
plate_order_related_field_definitions,
plate_order_related_worksheet_map, plate_order_related_worksheet_map,
plate_order_related_worksheet_map_cn, plate_order_related_worksheet_map_cn,
plate_order_relation_control_map,
plate_order_type_map, plate_order_type_map,
product_type_map, product_type_map,
) )
@@ -45,6 +47,13 @@ from .models import (
ProductListResponse, ProductListResponse,
) )
from .parsers import pick_customer, pick_fabric, pick_product 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__ = [ __all__ = [
# client # client
@@ -71,8 +80,10 @@ __all__ = [
"customer_type_map", "customer_type_map",
"fabric_type_map", "fabric_type_map",
"plate_order_field_definitions", "plate_order_field_definitions",
"plate_order_related_field_definitions",
"plate_order_related_worksheet_map", "plate_order_related_worksheet_map",
"plate_order_related_worksheet_map_cn", "plate_order_related_worksheet_map_cn",
"plate_order_relation_control_map",
"plate_order_type_map", "plate_order_type_map",
# models # models
"Product", "Product",
@@ -87,6 +98,12 @@ __all__ = [
"pick_product", "pick_product",
"pick_customer", "pick_customer",
"pick_fabric", "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
"fetch_products_from_mingdaoyun", "fetch_products_from_mingdaoyun",
"fetch_customers_from_mingdaoyun", "fetch_customers_from_mingdaoyun",

View File

@@ -126,7 +126,12 @@ async def fetch_row_by_rowid_from_mingdaoyun(
if not rows: if not rows:
return None return None
if isinstance(rows[0], dict): if isinstance(rows[0], dict):
return rows[0] first = rows[0]
# 明道云在 filters 不合法/不生效时可能仍返回默认列表数据。
# 这里做一次校验,避免“误返回不匹配的第一条记录”。
if first.get("rowid") != rowid:
return None
return first
return None return None

View File

@@ -45,6 +45,158 @@ plate_order_related_worksheet_map_cn = {
"照图开发": MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT, "照图开发": 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 # 明道云:字段映射(内部字段名 -> controlId
@@ -165,3 +317,19 @@ plate_order_type_map = {
"created_at": "ctime", "created_at": "ctime",
"rowid": "rowid", "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
# )

View 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

View File

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

View File

@@ -13,6 +13,12 @@ class PlateOrder(ModelBase):
# 自动编号相关 # 自动编号相关
design_code = models.CharField(max_length=50, blank=True, null=True, verbose_name='设计编号') 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='起版情况') # 首版/复版等 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='一段尺寸') size = models.CharField(max_length=100, null=True, blank=True, verbose_name='一段尺寸')
pieces = models.PositiveIntegerField(null=True, blank=True, verbose_name='件数') pieces = models.PositiveIntegerField(null=True, blank=True, verbose_name='件数')
description = models.TextField(blank=True, null=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( business_object = models.OneToOneField(
stateflow_models.BusinessObject, stateflow_models.BusinessObject,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,