From 98d797221ada09851522c78397cc07bcb62f84cd Mon Sep 17 00:00:00 2001 From: colaftc Date: Wed, 17 Dec 2025 17:37:39 +0800 Subject: [PATCH] feat: refactor flower.utils to sub package, added some sync api in package, added plate_order sync result model but relation of plate_order was not query (do it next step) --- api_v1/admin.py | 12 +- .../0004_mdy_plate_order_staging.py | 27 ++ api_v1/models.py | 33 ++ api_v1/tasks.py | 58 ++- api_v1/test_mingdaoyun_utils.py | 388 ++++++++++++++++++ business/services.py | 6 +- celerybeat-schedule | Bin 12288 -> 12288 bytes celerybeat-schedule-shm | Bin 32768 -> 32768 bytes celerybeat-schedule-wal | Bin 1334912 -> 304912 bytes flower/settings.py | 2 +- flower/utils.py | 386 ----------------- flower/utils/__init__.py | 100 +++++ flower/utils/mingdaoyun/__init__.py | 96 +++++ flower/utils/mingdaoyun/client.py | 109 +++++ flower/utils/mingdaoyun/fetch.py | 225 ++++++++++ flower/utils/mingdaoyun/mappings.py | 167 ++++++++ flower/utils/mingdaoyun/models.py | 142 +++++++ flower/utils/mingdaoyun/parsers.py | 39 ++ stateflow/services.py | 35 +- 19 files changed, 1422 insertions(+), 403 deletions(-) create mode 100644 api_v1/migrations/0004_mdy_plate_order_staging.py create mode 100644 api_v1/test_mingdaoyun_utils.py delete mode 100644 flower/utils.py create mode 100644 flower/utils/__init__.py create mode 100644 flower/utils/mingdaoyun/__init__.py create mode 100644 flower/utils/mingdaoyun/client.py create mode 100644 flower/utils/mingdaoyun/fetch.py create mode 100644 flower/utils/mingdaoyun/mappings.py create mode 100644 flower/utils/mingdaoyun/models.py create mode 100644 flower/utils/mingdaoyun/parsers.py diff --git a/api_v1/admin.py b/api_v1/admin.py index fca6214..5a675e6 100644 --- a/api_v1/admin.py +++ b/api_v1/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.contrib.admin import action -from api_v1.models import UploadedFile, DataSync +from api_v1.models import UploadedFile, DataSync, MDYPlateOrderStaging from .tasks import backup_database @@ -31,3 +31,13 @@ class DataSyncAdmin(admin.ModelAdmin): readonly_fields = ['created_at', 'updated_at'] date_hierarchy = 'created_at' ordering = ['-created_at'] + + +@admin.register(MDYPlateOrderStaging) +class MDYPlateOrderStagingAdmin(admin.ModelAdmin): + list_display = ['id', 'mdy_rowid', 'created_at', 'updated_at'] + list_filter = ['created_at'] + search_fields = ['mdy_rowid'] + readonly_fields = ['created_at', 'updated_at'] + date_hierarchy = 'created_at' + ordering = ['-created_at'] diff --git a/api_v1/migrations/0004_mdy_plate_order_staging.py b/api_v1/migrations/0004_mdy_plate_order_staging.py new file mode 100644 index 0000000..447a06c --- /dev/null +++ b/api_v1/migrations/0004_mdy_plate_order_staging.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api_v1', '0003_datasync'), + ] + + operations = [ + migrations.CreateModel( + name='MDYPlateOrderStaging', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('mdy_rowid', models.CharField(db_index=True, help_text='明道云行记录的 rowid(全局唯一)', max_length=64, unique=True, verbose_name='明道云 RowID')), + ('raw', models.JSONField(blank=True, default=dict, help_text='getFilterRows 返回的整行原始数据(包含各 controlId 字段)', verbose_name='原始行数据')), + ], + options={ + 'verbose_name': '明道云开版暂存', + 'verbose_name_plural': '明道云开版暂存', + 'db_table': 'api_mdy_plate_order_staging', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/api_v1/models.py b/api_v1/models.py index 83965aa..7027682 100644 --- a/api_v1/models.py +++ b/api_v1/models.py @@ -108,3 +108,36 @@ class DataSync(ModelBase): def __str__(self): return f'{self.table_name} @ {self.created_at:%Y-%m-%d %H:%M:%S}' + + +class MDYPlateOrderStaging(ModelBase): + """明道云“开版数据表”同步暂存表(不影响 printing.PlateOrder)。 + + 设计目标: + - 与现有业务模型完全隔离,避免影响业务数据与流程 + - 仅保留 rowid 便于检索排查,其余信息全部落在 raw(JSON) 中 + - 随着同步深入再逐步“属性化”字段(从 raw 实时提取/映射) + """ + + mdy_rowid = models.CharField( + max_length=64, + unique=True, + db_index=True, + verbose_name='明道云 RowID', + help_text='明道云行记录的 rowid(全局唯一)', + ) + raw = models.JSONField( + default=dict, + blank=True, + verbose_name='原始行数据', + help_text='getFilterRows 返回的整行原始数据(包含各 controlId 字段)', + ) + + class Meta: + db_table = 'api_mdy_plate_order_staging' + verbose_name = '明道云开版暂存' + verbose_name_plural = '明道云开版暂存' + ordering = ['-created_at'] + + def __str__(self): + return f'{self.mdy_rowid}' diff --git a/api_v1/tasks.py b/api_v1/tasks.py index 276f646..bc62fff 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -220,7 +220,11 @@ def _upsert_product(product_data, merchant, category): @shared_task(bind=True) def sync_mdy_products(self, page_size: int = 300, max_pages: int | None = None, max_records: int | None = None): """ - 从明道云同步产品数据(按 ctime 升序遍历,依赖 last_ctime/rowid 游标) + 从明道云同步产品数据(按 ctime 升序分页扫描)。 + + - 默认从上次同步记录的 page_index 继续翻页 + - last_ctime/last_rowid 用于页内游标(避免重复处理) + - max_pages 表示“单次任务最多处理多少页”(不是最大页码) """ merchant = _get_mdy_merchant() category = _get_mdy_category(merchant) @@ -231,15 +235,18 @@ def sync_mdy_products(self, page_size: int = 300, max_pages: int | None = None, ).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 = 1 + 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 page_index > max_pages: + # max_pages: 单次任务最多处理多少页(不是“最大页码”) + if max_pages is not None and pages_processed >= max_pages: break if max_records and synced_rows >= max_records: break @@ -249,8 +256,10 @@ def sync_mdy_products(self, page_size: int = 300, max_pages: int | None = None, if not products: break + hit_max_records = False for item in products: if max_records and synced_rows >= max_records: + hit_max_records = True break product_ctime = _parse_mdy_datetime(item.created_at) @@ -264,9 +273,18 @@ def sync_mdy_products(self, page_size: int = 300, max_pages: int | None = None, changed = _upsert_product(item, merchant, category) if changed: synced_rows += 1 - if product_ctime and (latest_ctime is None or product_ctime > latest_ctime): - latest_ctime = product_ctime - latest_rowid = item.rowid + if product_ctime: + if latest_ctime is None or product_ctime > latest_ctime: + latest_ctime = product_ctime + latest_rowid = item.rowid + elif product_ctime == latest_ctime: + # 同一秒内可能有多条记录,尽量把 rowid 推进到最后处理的那条 + latest_rowid = item.rowid + + pages_processed += 1 + if hit_max_records: + # 达到单次任务的记录上限:下次从同一页继续(依赖 last_ctime/last_rowid 跳过已处理部分) + break # 若返回不足一页,说明到尾部,可结束 if len(products) < page_size: @@ -334,7 +352,11 @@ def _upsert_customer(customer_data, merchant): @shared_task(bind=True) def sync_mdy_customers(self, page_size: int = 300, max_pages: int | None = None, max_records: int | None = None): """ - 从明道云同步客户数据(按 ctime 升序遍历,依赖 last_ctime/rowid 游标) + 从明道云同步客户数据(按 ctime 升序分页扫描)。 + + - 默认从上次同步记录的 page_index 继续翻页 + - last_ctime/last_rowid 用于页内游标(避免重复处理) + - max_pages 表示“单次任务最多处理多少页”(不是最大页码) """ merchant = _get_mdy_merchant() max_records = max_records or 0 # 0 表示不限制 @@ -344,15 +366,18 @@ def sync_mdy_customers(self, page_size: int = 300, max_pages: int | None = None, ).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 = 1 + 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 page_index > max_pages: + # max_pages: 单次任务最多处理多少页(不是“最大页码”) + if max_pages is not None and pages_processed >= max_pages: break if max_records and synced_rows >= max_records: break @@ -362,8 +387,10 @@ def sync_mdy_customers(self, page_size: int = 300, max_pages: int | None = None, if not customers: break + hit_max_records = False for item in customers: if max_records and synced_rows >= max_records: + hit_max_records = True break record_ctime = _parse_mdy_datetime(item.created_at) @@ -376,9 +403,16 @@ def sync_mdy_customers(self, page_size: int = 300, max_pages: int | None = None, changed = _upsert_customer(item, merchant) if changed: synced_rows += 1 - if record_ctime and (latest_ctime is None or record_ctime > latest_ctime): - latest_ctime = record_ctime - latest_rowid = item.rowid + if record_ctime: + if latest_ctime is None or record_ctime > latest_ctime: + latest_ctime = record_ctime + latest_rowid = item.rowid + elif record_ctime == latest_ctime: + latest_rowid = item.rowid + + pages_processed += 1 + if hit_max_records: + break if len(customers) < page_size: break diff --git a/api_v1/test_mingdaoyun_utils.py b/api_v1/test_mingdaoyun_utils.py new file mode 100644 index 0000000..29c2521 --- /dev/null +++ b/api_v1/test_mingdaoyun_utils.py @@ -0,0 +1,388 @@ +import asyncio +from unittest.mock import patch + +from django.test import SimpleTestCase + +from flower.utils.mingdaoyun import mappings +from flower.utils.mingdaoyun.client import MingDaoYunClient +from flower.utils.mingdaoyun.fetch import ( + fetch_customers_from_mingdaoyun, + fetch_plate_orders_from_mingdaoyun, + fetch_products_from_mingdaoyun, + fetch_row_by_rowid_from_mingdaoyun, + sync_fabric_from_mingdaoyun, +) +from flower.utils.mingdaoyun.models import Customer, Fabric, Product +from flower.utils.mingdaoyun.parsers import pick_customer, pick_fabric, pick_product + + +class _FakeMDYClient: + def __init__(self, response): + self.response = response + self.calls = [] + + async def post(self, endpoint, data=None, params=None, headers=None): + self.calls.append( + { + "endpoint": endpoint, + "data": data, + "params": params, + "headers": headers, + } + ) + return self.response + + +class _AsyncCM: + def __init__(self, obj): + self.obj = obj + + async def __aenter__(self): + return self.obj + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakeAiohttpResponse: + def __init__(self, json_body): + self._json_body = json_body + self.raise_for_status_called = False + + def raise_for_status(self): + self.raise_for_status_called = True + + async def json(self): + return self._json_body + + +class _FakeAiohttpSession: + def __init__(self): + self.post_calls = [] + + def post(self, url, json=None): + resp = _FakeAiohttpResponse({"ok": True}) + self.post_calls.append({"url": url, "json": json, "response": resp}) + return _AsyncCM(resp) + + +class _FakeAiohttpClientSession: + def __init__(self, *args, **kwargs): + self.headers = kwargs.get("headers") + self.session = _FakeAiohttpSession() + + async def __aenter__(self): + return self.session + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class MingDaoYunParsersTestCase(SimpleTestCase): + def test_pick_product_maps_fields_and_converts_numbers(self): + row = { + mappings.product_type_map["uid"]: "P-001", + mappings.product_type_map["rowid"]: "row-1", + mappings.product_type_map["created_at"]: "2025-12-01 00:00:00", + mappings.product_type_map["name"]: "产品A", + mappings.product_type_map["pieces"]: "12", + mappings.product_type_map["segment_size"]: "3", + mappings.product_type_map["unit"]: "米", + mappings.product_type_map["color"]: "红", + mappings.product_type_map["detail_str"]: '{"k": 1}', + } + + product = pick_product(row) + self.assertIsInstance(product, Product) + self.assertEqual(product.uid, "P-001") + self.assertEqual(product.rowid, "row-1") + self.assertEqual(product.created_at, "2025-12-01 00:00:00") + self.assertEqual(product.name, "产品A") + self.assertEqual(product.pieces, 12) + self.assertEqual(product.segment_size, 3) + # width 与 segment_size 当前共用同一个 controlId;保持同步代码现状 + self.assertEqual(product.width, "3") + self.assertEqual(product.unit, "米") + self.assertEqual(product.color, "红") + self.assertEqual(product.detail, {"k": 1}) + + def test_pick_product_invalid_numbers_become_none(self): + row = { + mappings.product_type_map["uid"]: "P-002", + mappings.product_type_map["rowid"]: "row-2", + mappings.product_type_map["created_at"]: "2025-12-01 00:00:00", + mappings.product_type_map["name"]: "产品B", + mappings.product_type_map["pieces"]: "invalid", + mappings.product_type_map["segment_size"]: "", + } + + product = pick_product(row) + self.assertIsNone(product.pieces) + self.assertIsNone(product.segment_size) + + def test_pick_customer_maps_fields(self): + row = { + mappings.customer_type_map["uid"]: "C-001", + mappings.customer_type_map["rowid"]: "crow-1", + mappings.customer_type_map["created_at"]: "2025-12-01 00:00:00", + mappings.customer_type_map["name"]: "客户甲", + mappings.customer_type_map["area"]: "杭州", + } + + customer = pick_customer(row) + self.assertIsInstance(customer, Customer) + self.assertEqual(customer.uid, "C-001") + self.assertEqual(customer.rowid, "crow-1") + self.assertEqual(customer.created_at, "2025-12-01 00:00:00") + self.assertEqual(customer.name, "客户甲") + self.assertEqual(customer.area, "杭州") + + def test_pick_fabric_maps_fields(self): + row = { + mappings.fabric_type_map["name"]: "120克本白四面弹", + } + + fabric = pick_fabric(row) + self.assertIsInstance(fabric, Fabric) + self.assertEqual(fabric.name, "120克本白四面弹") + + +class MingDaoYunFetchTestCase(SimpleTestCase): + def test_fetch_products_calls_client_with_expected_payload_and_parses(self): + response = { + "data": { + "rows": [ + { + mappings.product_type_map["uid"]: "P-001", + mappings.product_type_map["rowid"]: "row-1", + mappings.product_type_map["created_at"]: "2025-12-01 00:00:00", + mappings.product_type_map["name"]: "产品A", + mappings.product_type_map["pieces"]: "1", + mappings.product_type_map["segment_size"]: "2", + } + ], + "total": 123, + } + } + fake_client = _FakeMDYClient(response) + + with patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client): + products, total = asyncio.run(fetch_products_from_mingdaoyun(page=2, page_size=5)) + + self.assertEqual(total, 123) + self.assertEqual(len(products), 1) + self.assertIsInstance(products[0], Product) + self.assertEqual(products[0].uid, "P-001") + + self.assertEqual(len(fake_client.calls), 1) + call = fake_client.calls[0] + self.assertEqual(call["endpoint"], "/v2/open/worksheet/getFilterRows") + self.assertEqual(call["data"]["worksheetId"], mappings.MDY_WORKSHEET_ID_PRODUCT) + self.assertEqual(call["data"]["pageIndex"], 2) + self.assertEqual(call["data"]["pageSize"], 5) + self.assertEqual(call["data"]["sortId"], "ctime") + self.assertIs(call["data"]["isAsc"], True) + + def test_fetch_customers_calls_client_with_expected_payload_and_parses(self): + response = { + "data": { + "rows": [ + { + mappings.customer_type_map["uid"]: "C-001", + mappings.customer_type_map["rowid"]: "crow-1", + mappings.customer_type_map["created_at"]: "2025-12-01 00:00:00", + mappings.customer_type_map["name"]: "客户甲", + } + ], + "total": 9, + } + } + fake_client = _FakeMDYClient(response) + + with patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client): + customers, total = asyncio.run(fetch_customers_from_mingdaoyun(page=3, page_size=7)) + + self.assertEqual(total, 9) + self.assertEqual(len(customers), 1) + self.assertIsInstance(customers[0], Customer) + self.assertEqual(customers[0].uid, "C-001") + + call = fake_client.calls[0] + self.assertEqual(call["endpoint"], "/v2/open/worksheet/getFilterRows") + self.assertEqual(call["data"]["worksheetId"], mappings.MDY_WORKSHEET_ID_CUSTOMER) + self.assertEqual(call["data"]["pageIndex"], 3) + self.assertEqual(call["data"]["pageSize"], 7) + + def test_fetch_plate_orders_returns_raw_rows_and_total(self): + rows = [{"rowid": "r1", "ctime": "2025-12-17 10:07:00"}] + response = {"data": {"rows": rows, "total": 100}} + fake_client = _FakeMDYClient(response) + + with patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client): + got_rows, total = asyncio.run( + fetch_plate_orders_from_mingdaoyun(page=4, page_size=11, sort_id="ctime", is_asc=False) + ) + + self.assertEqual(total, 100) + self.assertEqual(got_rows, rows) + + call = fake_client.calls[0] + self.assertEqual(call["data"]["worksheetId"], mappings.MDY_WORKSHEET_ID_PLATE_ORDER) + self.assertEqual(call["data"]["pageIndex"], 4) + self.assertEqual(call["data"]["pageSize"], 11) + self.assertEqual(call["data"]["sortId"], "ctime") + self.assertIs(call["data"]["isAsc"], False) + + def test_fetch_products_returns_empty_when_no_data(self): + fake_client = _FakeMDYClient({}) + with patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client): + products, total = asyncio.run(fetch_products_from_mingdaoyun(page=1, page_size=1)) + + self.assertEqual(products, []) + self.assertEqual(total, 0) + + def test_fetch_row_by_rowid_builds_filters_payload_and_returns_first_row(self): + rowid = "row-xyz" + row = { + "rowid": rowid, + mappings.product_type_map["uid"]: "P-ROW", + } + response = {"data": {"rows": [row]}} + fake_client = _FakeMDYClient(response) + + with patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client): + got = asyncio.run( + fetch_row_by_rowid_from_mingdaoyun( + worksheet_id=mappings.MDY_WORKSHEET_ID_PRODUCT, + rowid=rowid, + ) + ) + + self.assertEqual(got, row) + self.assertEqual(len(fake_client.calls), 1) + call = fake_client.calls[0] + self.assertEqual(call["endpoint"], "/v2/open/worksheet/getFilterRows") + self.assertEqual(call["data"]["worksheetId"], mappings.MDY_WORKSHEET_ID_PRODUCT) + self.assertEqual(call["data"]["listType"], 1) + self.assertEqual(call["data"]["sortId"], "ctime") + self.assertIs(call["data"]["isAsc"], False) + self.assertIs(call["data"]["notGetTotal"], True) + self.assertEqual( + call["data"]["filters"], + [ + { + "controlId": "rowId", + "dataType": 2, + "filterType": 3, + "value": rowid, + } + ], + ) + + def test_fetch_row_by_rowid_returns_none_when_no_data(self): + fake_client = _FakeMDYClient({}) + with patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client): + got = asyncio.run( + fetch_row_by_rowid_from_mingdaoyun( + worksheet_id=mappings.MDY_WORKSHEET_ID_PRODUCT, + rowid="missing", + ) + ) + self.assertIsNone(got) + + +class MingDaoYunFabricSyncTestCase(SimpleTestCase): + def test_sync_fabric_calls_create_hook_and_returns_total(self): + response = { + "data": { + "rows": [ + {mappings.fabric_type_map["name"]: "布料A"}, + {mappings.fabric_type_map["name"]: "布料B"}, + ], + "total": 2, + } + } + fake_client = _FakeMDYClient(response) + called = {} + + def fake_create(page, page_size, fabrics): + called["page"] = page + called["page_size"] = page_size + called["fabrics"] = fabrics + + def fake_sync_to_async(func): + async def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + with ( + patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client), + patch("flower.utils.mingdaoyun.fetch._create_fabric_quick_inputs", side_effect=fake_create), + patch("flower.utils.mingdaoyun.fetch.sync_to_async", side_effect=fake_sync_to_async), + ): + total = asyncio.run(sync_fabric_from_mingdaoyun(page=9, page_size=10)) + + self.assertEqual(total, 2) + self.assertEqual(called["page"], 9) + self.assertEqual(called["page_size"], 10) + self.assertEqual([f.name for f in called["fabrics"]], ["布料A", "布料B"]) + + def test_sync_fabric_returns_zero_when_no_data(self): + fake_client = _FakeMDYClient({}) + called = {"hit": False} + + def fake_create(*_args, **_kwargs): + called["hit"] = True + + def fake_sync_to_async(func): + async def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + with ( + patch("flower.utils.mingdaoyun.fetch.get_default_mingdaoyun_client", return_value=fake_client), + patch("flower.utils.mingdaoyun.fetch._create_fabric_quick_inputs", side_effect=fake_create), + patch("flower.utils.mingdaoyun.fetch.sync_to_async", side_effect=fake_sync_to_async), + ): + total = asyncio.run(sync_fabric_from_mingdaoyun(page=1, page_size=1)) + + self.assertEqual(total, 0) + self.assertIs(called["hit"], False) + + +class MingDaoYunClientTestCase(SimpleTestCase): + def test_client_post_merges_auth_params_and_passes_headers(self): + created = {} + + def fake_client_session(*args, **kwargs): + cs = _FakeAiohttpClientSession(*args, **kwargs) + created["cs"] = cs + return cs + + client = MingDaoYunClient(app_key="ak", sign="sg", base_url="https://api.mingdao.com") + with patch("flower.utils.mingdaoyun.client.aiohttp.ClientSession", side_effect=fake_client_session): + body = asyncio.run( + client.post( + endpoint="/v2/open/worksheet/getFilterRows", + data={"worksheetId": "dummy", "pageIndex": 1}, + headers={"X-Test": "1"}, + ) + ) + + self.assertEqual(body, {"ok": True}) + + cs = created["cs"] + self.assertIsInstance(cs.headers, dict) + self.assertEqual(cs.headers.get("Content-Type"), "application/json") + self.assertEqual(cs.headers.get("X-Test"), "1") + + self.assertEqual(len(cs.session.post_calls), 1) + call = cs.session.post_calls[0] + self.assertEqual(call["url"], "https://api.mingdao.com/v2/open/worksheet/getFilterRows") + self.assertEqual(call["json"]["worksheetId"], "dummy") + self.assertEqual(call["json"]["pageIndex"], 1) + self.assertEqual(call["json"]["appKey"], "ak") + self.assertEqual(call["json"]["sign"], "sg") + self.assertTrue(call["response"].raise_for_status_called) diff --git a/business/services.py b/business/services.py index f3c24da..537b921 100644 --- a/business/services.py +++ b/business/services.py @@ -1723,8 +1723,10 @@ def _to_decimal(value, field_name: str) -> Decimal: def _ensure_non_zero_amount(value, field_name: str) -> Decimal: amount = _to_decimal(value, field_name) - if amount == 0: - raise ValueError(f'{field_name} 不能为 0') + # 金额类字段统一要求 > 0(amount ≤ 0 视为非法) + if amount <= 0: + # 与 API 文档/测试约定保持一致 + raise ValueError(f'{field_name} 必须大于 0') return amount diff --git a/celerybeat-schedule b/celerybeat-schedule index 4668b5485943f01134e3a4939964744809d30196..5ac5ce23b18787ba1e2a52c0f6c3a5e64e468456 100644 GIT binary patch delta 162 zcmZojXh_(wU506@&E%aj%h&~(8Gt~0a++*3BhTbRvZn+D`9UHYQ!>QbrUXs#{Xcn* z+)`;lepaBM4ph+lQiilf53`oO>68qa$uaWoOjDI6FO^popQ^~LBvM+E9G{w(n3R*6 z(!koGr#Mw%^L_cB{2E*f*_j2HN~*XP@Gwg;rRJ3sWu_K$?Ga#BVv3J1OD!tS%+HID H=UM~+t_Lyg delta 162 zcmZojXh_(wU506L#N?ea%h&~&8GyiOa++*3BlqM(vZn+D#6TiuQ!>QbrUXs#ojG}q z+)`-)5ug~;R;Zx&)(mM4Z-yRbEq&7|lVjxFnI?EnUMjCFKEZ=oNu;zSIX*QnF)1fC zrGd3WPjQ0#=KJzL`87B^*qH^GN~$>Ad6=b`Qu9iRGE<8=ZwN3eF~!H1r4|)u=I6!7 Hb9e#(w&5{e diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm index 6b7007dbedcfd71f6419591503007caa1e146b84..b273ec8069b874f04449be16844abaa0bac0c0ae 100644 GIT binary patch delta 166 zcmZo@U}|V!s+V}A%K!qZK+MR%Am9b0nSnU(>2>e7S7cdux2r4e6X%?D+&qId#bNUX))&l^|G0W>Uc|bEY4R@*5Mz%S05Fa;A^-pY delta 688 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 N0+D)wrT^9o{2!}<&#V9d diff --git a/flower/settings.py b/flower/settings.py index 03390c5..7c07549 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -321,7 +321,7 @@ CELERY_BEAT_SCHEDULE = { }, 'mdy_customer_sync': { 'task': 'api_v1.tasks.sync_mdy_customers', - 'schedule': crontab(hour='*/5', minute=0), + 'schedule': crontab(minute='*/5'), 'kwargs': { 'page_size': MDY_SYNC_PAGE_SIZE, 'max_pages': MDY_SYNC_MAX_PAGES, diff --git a/flower/utils.py b/flower/utils.py deleted file mode 100644 index 24af9db..0000000 --- a/flower/utils.py +++ /dev/null @@ -1,386 +0,0 @@ -import aiohttp -from typing import Optional, Dict, Any, List -from pydantic import BaseModel, Field, computed_field -from enum import Enum -import json -from asgiref.sync import sync_to_async - - -class Product(BaseModel): - """产品模型 - 请在此填入你的字段""" - uid: str - rowid: str - name: str - pieces: int | None - segment_size: int | None - unit: str | None - color: str | None - width: str | None = None - detail_str: str | None = Field(exclude=True) # 在序列化时排除此字段 - created_at: str - - @computed_field - @property - def detail(self) -> dict[str, Any]: - """将 detail_str 转换为字典""" - try: - return json.loads(self.detail_str) if self.detail_str else {} - except (json.JSONDecodeError, TypeError): - return {} - - -class ProductListResponse(BaseModel): - """产品列表响应""" - products: List[Product] - total: int - - -mdy_table_map = { - 'product': 'spmx', - 'customer': 'quanbu', -} - - -class HTTPMethod(Enum): - """HTTP 请求方法枚举""" - GET = "GET" - POST = "POST" - - -class MingDaoYunClient: - """明道云 API 客户端""" - - def __init__(self, app_key: str, sign: str, base_url: str = ""): - """ - 初始化客户端 - - Args: - app_key: 应用密钥 - sign: 签名 - base_url: API 基础地址 - """ - self.app_key = app_key - self.sign = sign - self.base_url = base_url - - async def request( - self, - method: HTTPMethod, - endpoint: str, - data: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - ) -> Any: - """ - 通用异步请求函数 - - Args: - method: 请求方法 (GET/POST) - endpoint: API 端点路径 - data: POST 请求体数据(JSON) - params: URL 查询参数 - headers: 自定义请求头 - - Returns: - 响应 JSON 数据 - - Raises: - aiohttp.ClientError: 请求失败时抛出 - """ - url = f"{self.base_url}{endpoint}" - - # 构建默认请求头 - default_headers = { - "Content-Type": "application/json", - } - - if headers: - default_headers.update(headers) - - # 添加认证参数 - auth_params = { - "appKey": self.app_key, - "sign": self.sign - } - - async with aiohttp.ClientSession() as session: - if method == HTTPMethod.GET: - async with session.get(url, params=params, json=auth_params) as response: - response.raise_for_status() - return await response.json() - - elif method == HTTPMethod.POST: - if data is None: - data = {} - - data.update(auth_params) - async with session.post(url, json=data) as response: - response.raise_for_status() - return await response.json() - - else: - raise ValueError(f"Unsupported HTTP method: {method}") - - async def get( - self, - endpoint: str, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - ) -> Any: - """ - 发送 GET 请求 - - Args: - endpoint: API 端点路径 - params: URL 查询参数 - headers: 自定义请求头 - - Returns: - 响应 JSON 数据 - """ - return await self.request( - method=HTTPMethod.GET, - endpoint=endpoint, - params=params, - headers=headers, - ) - - async def post( - self, - endpoint: str, - data: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - ) -> Any: - """ - 发送 POST 请求 - - Args: - endpoint: API 端点路径 - data: POST 请求体数据(JSON) - params: URL 查询参数 - headers: 自定义请求头 - - Returns: - 响应 JSON 数据 - """ - return await self.request( - method=HTTPMethod.POST, - endpoint=endpoint, - data=data, - params=params, - headers=headers, - ) - - -product_type_map = { - 'name': '668caa5eb80969563ecaee7d', - 'pieces': '689c5aa37c175367b29fcb87', - 'color': '66ed3b9ae01d5599bdb45f6d', - 'uid': '668caa5eb80969563ecaee7c', - 'unit': '668e2ee2ca758c8cc5d0dc1d', - 'segment_size': '668e2194790c0e04058b4f3c', - 'width': '668e2194790c0e04058b4f3c', - 'detail_str': '668caa5eb80969563ecaee7e', - 'created_at': 'ctime', - 'rowid': 'rowid', -} - -customer_type_map = { - 'name': '62d52f4b8d2972284492dcf9', - 'area': '62d52f4b8d2972284492dd09', - 'created_at': 'ctime', - 'rowid': 'rowid', - 'uid': '668bb9370207cf7520fe551e', -} - -class Customer(BaseModel): - """客户模型 - 请在此填入你的字段""" - uid: str - rowid: str - name: str - area: str | None - created_at: str - - -def pick_customer(fields: dict) -> Customer: - """从字段字典中提取客户信息""" - data = {k: fields.get(v, '') for k, v in customer_type_map.items()} - return Customer(**data) - - -# json param example: -# { -# "appKey": "208e55fea5cea59f", -# "sign": "MWU0YmViYjkwZmM1ZDIzYzRiN2U3ZGQ4MmE4ZGNkMjc0MWM1ZmQ2ZjkwMjljODE4YmNkZTBhMzA0OTU2YzE2NA==", -# "worksheetId": "quanbu", -# "listType": 1, -# "sortId": "ctime", -# "isAsc": false, -# "notGetTotal": true -# } - - -def pick_product(fields: dict) -> Product: - """ - 从字段字典中提取产品信息 - """ - data = {k: fields.get(v, '') for k, v in product_type_map.items()} - - def _to_int(value): - if value in ('', None): - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - data['pieces'] = _to_int(data.get('pieces')) - data['segment_size'] = _to_int(data.get('segment_size')) - - return Product(**data) - - -async def fetch_products_from_mingdaoyun(page: int = 1, page_size: int = 100) -> tuple[list[Product], int]: - """ - 从明道云获取产品列表 - - Returns: - tuple: (产品列表, 总数量) - """ - client = MingDaoYunClient( - app_key='208e55fea5cea59f', - sign='MWU0YmViYjkwZmM1ZDIzYzRiN2U3ZGQ4MmE4ZGNkMjc0MWM1ZmQ2ZjkwMjljODE4YmNkZTBhMzA0OTU2YzE2NA==', - base_url="https://api.mingdao.com" - ) - response = await client.post( - endpoint='/v2/open/worksheet/getFilterRows', - data={ - 'worksheetId': 'spmx', - 'pageIndex': page, - 'pageSize': page_size, - 'sortId': 'ctime', - 'isAsc': True, - } - ) - data = response.get('data') - if data: - print(f'[product-sync] page={page} size={page_size} rows={len(data.get("rows", []))} total={data.get("total")}') - else: - print(f'[product-sync] page={page} size={page_size} received empty data') - if not data: - return [], 0 - - products = [pick_product(item) for item in data.get('rows', [])] - total_count = data.get('total', 0) - return products, total_count - - -async def fetch_customers_from_mingdaoyun(page: int = 1, page_size: int = 100) -> tuple[list[Customer], int]: - """ - 从明道云获取客户列表 - """ - client = MingDaoYunClient( - app_key='208e55fea5cea59f', - sign='MWU0YmViYjkwZmM1ZDIzYzRiN2U3ZGQ4MmE4ZGNkMjc0MWM1ZmQ2ZjkwMjljODE4YmNkZTBhMzA0OTU2YzE2NA==', - base_url="https://api.mingdao.com" - ) - response = await client.post( - endpoint='/v2/open/worksheet/getFilterRows', - data={ - 'worksheetId': 'quanbu', - 'pageIndex': page, - 'pageSize': page_size, - 'sortId': 'ctime', - 'isAsc': True, - } - ) - data = response.get('data') - if data: - print(f'[customer-sync] page={page} size={page_size} rows={len(data.get("rows", []))} total={data.get("total")}') - else: - print(f'[customer-sync] page={page} size={page_size} received empty data') - if not data: - return [], 0 - - customers = [pick_customer(item) for item in data.get('rows', [])] - total_count = data.get('total', 0) - return customers, total_count - - -fabric_type_map = { - 'name': '62d52f4b8d2972284492de61', -} - - -class Fabric(BaseModel): - """面料模型 - 请在此填入你的字段""" - name: str - - -def pick_fabric(fields: dict) -> Fabric: - """ - 从字段字典中提取面料信息 - """ - data = {k: fields.get(v, '') for k, v in fabric_type_map.items()} - return Fabric(**data) - - -async def sync_fabric_from_mingdaoyun(page: int = 1, page_size: int = 100) -> int: - """ - 从明道云同步面料数据 - """ - client = MingDaoYunClient( - app_key='208e55fea5cea59f', - sign='MWU0YmViYjkwZmM1ZDIzYzRiN2U3ZGQ4MmE4ZGNkMjc0MWM1ZmQ2ZjkwMjljODE4YmNkZTBhMzA0OTU2YzE2NA==', - base_url="https://api.mingdao.com" - ) - response = await client.post( - endpoint='/v2/open/worksheet/getFilterRows', - data={ - 'worksheetId': '668ba100fb551c850214067d', - 'pageIndex': page, - 'pageSize': page_size, - 'sortId': 'ctime', - 'isAsc': False, - } - ) - data = response.get('data') - if data: - rows = data.get("rows", []) - print(f'[fabric-sync] fetch rows={len(rows)} total={data.get("total")}') - if rows: - print(f'[fabric-sync] first row keys: {list(rows[0].keys())[:10]}') - print(f'[fabric-sync] first row sample: {rows[0]}') - else: - print(f'[fabric-sync] fetch empty data (page={page}, size={page_size})') - if not data: - return [], 0 - fabrics = [pick_fabric(item) for item in data.get('rows', [])] - total_count = data.get('total', 0) - await sync_to_async(_create_fabric_quick_inputs)( - page, - page_size, - fabrics - ) - return total_count - - -def _create_fabric_quick_inputs(page: int, page_size: int, fabrics: list[Fabric]) -> None: - from basic_info import models as basic_models - total = len(fabrics) - created_count = 0 - sample_names = [fabric.name for fabric in fabrics[:5]] - print(f'[fabric-sync] page={page} size={page_size} fetched={total}') - print(f'[fabric-sync] sample names: {sample_names}') - for fabric in fabrics: - if not fabric.name: - continue - obj, created = basic_models.QuickInput.objects.update_or_create( - name=fabric.name, - group='布料名', - defaults={'value': fabric.name}, - ) - if created: - created_count += 1 - print(f'[fabric-sync] page={page} new_records={created_count} updated_or_existing={total - created_count}') diff --git a/flower/utils/__init__.py b/flower/utils/__init__.py new file mode 100644 index 0000000..e3ddabd --- /dev/null +++ b/flower/utils/__init__.py @@ -0,0 +1,100 @@ +"""工具包。 + +历史上 `flower.utils` 是一个单文件模块(`utils.py`)。 +为了降低耦合并便于扩展,现已拆分为目录包,并在此保留向后兼容的导出。 + +- 明道云相关:`flower.utils.mingdaoyun.*` +""" + +from .mingdaoyun import ( + HTTPMethod, + MDY_APP_KEY, + MDY_BASE_URL, + MDY_ENDPOINT_GET_FILTER_ROWS, + MDY_SIGN, + MDY_WORKSHEET_ID_CUSTOMER, + MDY_WORKSHEET_ID_FABRIC, + MDY_WORKSHEET_ID_PLATE_ORDER, + MDY_WORKSHEET_ID_PLATE_ORDER_COLORING, + MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME, + MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING, + MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT, + MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING, + MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET, + MDY_WORKSHEET_ID_PRODUCT, + Customer, + Fabric, + MDYAttachmentItem, + MDYCollaboratorItem, + MDYPlateOrder, + MDYRelationItem, + MingDaoYunClient, + Product, + ProductListResponse, + customer_type_map, + fabric_type_map, + fetch_customers_from_mingdaoyun, + fetch_plate_orders_from_mingdaoyun, + fetch_products_from_mingdaoyun, + fetch_row_by_rowid_from_mingdaoyun, + get_default_mingdaoyun_client, + mdy_table_map, + pick_customer, + pick_fabric, + pick_product, + plate_order_field_definitions, + plate_order_related_worksheet_map, + plate_order_related_worksheet_map_cn, + plate_order_type_map, + product_type_map, + sync_fabric_from_mingdaoyun, +) + +__all__ = [ + # client + "HTTPMethod", + "MingDaoYunClient", + "get_default_mingdaoyun_client", + "MDY_BASE_URL", + "MDY_ENDPOINT_GET_FILTER_ROWS", + "MDY_APP_KEY", + "MDY_SIGN", + # worksheet ids / maps + "mdy_table_map", + "MDY_WORKSHEET_ID_PRODUCT", + "MDY_WORKSHEET_ID_CUSTOMER", + "MDY_WORKSHEET_ID_FABRIC", + "MDY_WORKSHEET_ID_PLATE_ORDER", + "MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING", + "MDY_WORKSHEET_ID_PLATE_ORDER_COLORING", + "MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET", + "MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING", + "MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME", + "MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT", + "product_type_map", + "customer_type_map", + "fabric_type_map", + "plate_order_field_definitions", + "plate_order_related_worksheet_map", + "plate_order_related_worksheet_map_cn", + "plate_order_type_map", + # models + "Product", + "ProductListResponse", + "Customer", + "Fabric", + "MDYRelationItem", + "MDYAttachmentItem", + "MDYCollaboratorItem", + "MDYPlateOrder", + # parsers + "pick_product", + "pick_customer", + "pick_fabric", + # fetch + "fetch_products_from_mingdaoyun", + "fetch_customers_from_mingdaoyun", + "fetch_row_by_rowid_from_mingdaoyun", + "fetch_plate_orders_from_mingdaoyun", + "sync_fabric_from_mingdaoyun", +] diff --git a/flower/utils/mingdaoyun/__init__.py b/flower/utils/mingdaoyun/__init__.py new file mode 100644 index 0000000..e539648 --- /dev/null +++ b/flower/utils/mingdaoyun/__init__.py @@ -0,0 +1,96 @@ +from .client import ( + HTTPMethod, + MDY_APP_KEY, + MDY_BASE_URL, + MDY_ENDPOINT_GET_FILTER_ROWS, + MDY_SIGN, + MingDaoYunClient, + get_default_mingdaoyun_client, +) +from .fetch import ( + fetch_customers_from_mingdaoyun, + fetch_plate_orders_from_mingdaoyun, + fetch_products_from_mingdaoyun, + fetch_row_by_rowid_from_mingdaoyun, + sync_fabric_from_mingdaoyun, +) +from .mappings import ( + MDY_WORKSHEET_ID_CUSTOMER, + MDY_WORKSHEET_ID_FABRIC, + MDY_WORKSHEET_ID_PLATE_ORDER, + MDY_WORKSHEET_ID_PLATE_ORDER_COLORING, + MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME, + MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING, + MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT, + MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING, + MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET, + MDY_WORKSHEET_ID_PRODUCT, + customer_type_map, + fabric_type_map, + mdy_table_map, + plate_order_field_definitions, + plate_order_related_worksheet_map, + plate_order_related_worksheet_map_cn, + plate_order_type_map, + product_type_map, +) +from .models import ( + Customer, + Fabric, + MDYAttachmentItem, + MDYCollaboratorItem, + MDYPlateOrder, + MDYRelationItem, + Product, + ProductListResponse, +) +from .parsers import pick_customer, pick_fabric, pick_product + +__all__ = [ + # client + "HTTPMethod", + "MingDaoYunClient", + "get_default_mingdaoyun_client", + "MDY_BASE_URL", + "MDY_ENDPOINT_GET_FILTER_ROWS", + "MDY_APP_KEY", + "MDY_SIGN", + # worksheet ids / maps + "mdy_table_map", + "MDY_WORKSHEET_ID_PRODUCT", + "MDY_WORKSHEET_ID_CUSTOMER", + "MDY_WORKSHEET_ID_FABRIC", + "MDY_WORKSHEET_ID_PLATE_ORDER", + "MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING", + "MDY_WORKSHEET_ID_PLATE_ORDER_COLORING", + "MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET", + "MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING", + "MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME", + "MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT", + "product_type_map", + "customer_type_map", + "fabric_type_map", + "plate_order_field_definitions", + "plate_order_related_worksheet_map", + "plate_order_related_worksheet_map_cn", + "plate_order_type_map", + # models + "Product", + "ProductListResponse", + "Customer", + "Fabric", + "MDYRelationItem", + "MDYAttachmentItem", + "MDYCollaboratorItem", + "MDYPlateOrder", + # parsers + "pick_product", + "pick_customer", + "pick_fabric", + # fetch + "fetch_products_from_mingdaoyun", + "fetch_customers_from_mingdaoyun", + "fetch_row_by_rowid_from_mingdaoyun", + "fetch_plate_orders_from_mingdaoyun", + "sync_fabric_from_mingdaoyun", +] diff --git a/flower/utils/mingdaoyun/client.py b/flower/utils/mingdaoyun/client.py new file mode 100644 index 0000000..a75cc16 --- /dev/null +++ b/flower/utils/mingdaoyun/client.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, Optional + +import aiohttp + +# 明道云开放接口:基础配置 +MDY_BASE_URL = "https://api.mingdao.com" +MDY_ENDPOINT_GET_FILTER_ROWS = "/v2/open/worksheet/getFilterRows" + +# NOTE: +# - 目前项目里 appKey/sign 仍是硬编码(与现有同步代码保持一致) +# - 后续如果要做多环境/更安全配置,建议迁移到环境变量或 Django settings +MDY_APP_KEY = "208e55fea5cea59f" +MDY_SIGN = "MWU0YmViYjkwZmM1ZDIzYzRiN2U3ZGQ4MmE4ZGNkMjc0MWM1ZmQ2ZjkwMjljODE4YmNkZTBhMzA0OTU2YzE2NA==" + + +class HTTPMethod(Enum): + """HTTP 请求方法枚举""" + + GET = "GET" + POST = "POST" + + +class MingDaoYunClient: + """明道云 API 客户端(异步)""" + + def __init__(self, app_key: str, sign: str, base_url: str = MDY_BASE_URL): + self.app_key = app_key + self.sign = sign + self.base_url = base_url + + async def request( + self, + method: HTTPMethod, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Any: + """通用异步请求函数""" + + url = f"{self.base_url}{endpoint}" + + default_headers: Dict[str, str] = { + "Content-Type": "application/json", + } + if headers: + default_headers.update(headers) + + # 添加认证参数 + auth_params = { + "appKey": self.app_key, + "sign": self.sign, + } + + async with aiohttp.ClientSession(headers=default_headers) as session: + if method == HTTPMethod.GET: + async with session.get(url, params=params, json=auth_params) as response: + response.raise_for_status() + return await response.json() + + if method == HTTPMethod.POST: + payload: Dict[str, Any] = dict(data or {}) + payload.update(auth_params) + async with session.post(url, json=payload) as response: + response.raise_for_status() + return await response.json() + + raise ValueError(f"Unsupported HTTP method: {method}") + + async def get( + self, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Any: + """发送 GET 请求""" + + return await self.request( + method=HTTPMethod.GET, + endpoint=endpoint, + params=params, + headers=headers, + ) + + async def post( + self, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Any: + """发送 POST 请求""" + + return await self.request( + method=HTTPMethod.POST, + endpoint=endpoint, + data=data, + params=params, + headers=headers, + ) + + +def get_default_mingdaoyun_client(base_url: str = MDY_BASE_URL) -> MingDaoYunClient: + """获取默认配置的明道云客户端(复用项目现有 appKey/sign)""" + + return MingDaoYunClient(app_key=MDY_APP_KEY, sign=MDY_SIGN, base_url=base_url) diff --git a/flower/utils/mingdaoyun/fetch.py b/flower/utils/mingdaoyun/fetch.py new file mode 100644 index 0000000..a48301f --- /dev/null +++ b/flower/utils/mingdaoyun/fetch.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from typing import Any + +from asgiref.sync import sync_to_async + +from .client import MDY_ENDPOINT_GET_FILTER_ROWS, get_default_mingdaoyun_client +from .mappings import ( + MDY_WORKSHEET_ID_CUSTOMER, + MDY_WORKSHEET_ID_FABRIC, + MDY_WORKSHEET_ID_PLATE_ORDER, + MDY_WORKSHEET_ID_PRODUCT, +) +from .models import Customer, Fabric, Product +from .parsers import pick_customer, pick_fabric, pick_product + + +async def fetch_products_from_mingdaoyun(page: int = 1, page_size: int = 100) -> tuple[list[Product], int]: + """从明道云获取产品列表""" + + client = get_default_mingdaoyun_client() + response = await client.post( + endpoint=MDY_ENDPOINT_GET_FILTER_ROWS, + data={ + "worksheetId": MDY_WORKSHEET_ID_PRODUCT, + "pageIndex": page, + "pageSize": page_size, + "sortId": "ctime", + "isAsc": True, + }, + ) + + data = response.get("data") if isinstance(response, dict) else None + if data: + print( + f'[product-sync] page={page} size={page_size} rows={len(data.get("rows", []))} total={data.get("total")}' + ) + else: + print(f"[product-sync] page={page} size={page_size} received empty data") + if not data: + return [], 0 + + products = [pick_product(item) for item in data.get("rows", [])] + total_count = data.get("total", 0) + return products, total_count + + +async def fetch_customers_from_mingdaoyun(page: int = 1, page_size: int = 100) -> tuple[list[Customer], int]: + """从明道云获取客户列表""" + + client = get_default_mingdaoyun_client() + response = await client.post( + endpoint=MDY_ENDPOINT_GET_FILTER_ROWS, + data={ + "worksheetId": MDY_WORKSHEET_ID_CUSTOMER, + "pageIndex": page, + "pageSize": page_size, + "sortId": "ctime", + "isAsc": True, + }, + ) + + data = response.get("data") if isinstance(response, dict) else None + if data: + print( + f'[customer-sync] page={page} size={page_size} rows={len(data.get("rows", []))} total={data.get("total")}' + ) + else: + print(f"[customer-sync] page={page} size={page_size} received empty data") + if not data: + return [], 0 + + customers = [pick_customer(item) for item in data.get("rows", [])] + total_count = data.get("total", 0) + return customers, total_count + + +async def fetch_row_by_rowid_from_mingdaoyun( + worksheet_id: str, + rowid: str, + *, + sort_id: str = "ctime", + is_asc: bool = False, + list_type: int = 1, + not_get_total: bool = True, + data_type: int = 2, + filter_type: int = 3, + control_id: str = "rowId", +) -> dict[str, Any] | None: + """按 rowId 查询工作表的单条记录(仅封装 rowId 等值查询)。 + + 按照明道云 getFilterRows 的 filters 结构构造请求: + - dataType=2 表示字符串 + - filterType=3 表示“相等” + + 返回: + - 匹配到记录:返回第一条 row dict + - 未匹配或响应无 data:返回 None + """ + + client = get_default_mingdaoyun_client() + response = await client.post( + endpoint=MDY_ENDPOINT_GET_FILTER_ROWS, + data={ + "worksheetId": worksheet_id, + "listType": list_type, + "sortId": sort_id, + "isAsc": is_asc, + "notGetTotal": not_get_total, + "filters": [ + { + "controlId": control_id, + "dataType": data_type, + "filterType": filter_type, + "value": rowid, + } + ], + }, + ) + + data = response.get("data") if isinstance(response, dict) else None + if not data: + return None + + rows = data.get("rows") or [] + if not rows: + return None + if isinstance(rows[0], dict): + return rows[0] + return None + + +async def fetch_plate_orders_from_mingdaoyun( + page: int = 1, + page_size: int = 100, + *, + sort_id: str = "ctime", + is_asc: bool = True, +) -> tuple[list[dict[str, Any]], int]: + """从明道云获取“开版数据表”记录列表(原始行字典,不做字段映射解析)""" + + client = get_default_mingdaoyun_client() + response = await client.post( + endpoint=MDY_ENDPOINT_GET_FILTER_ROWS, + data={ + "worksheetId": MDY_WORKSHEET_ID_PLATE_ORDER, + "pageIndex": page, + "pageSize": page_size, + "sortId": sort_id, + "isAsc": is_asc, + }, + ) + + data = response.get("data") if isinstance(response, dict) else None + if data: + print( + f'[plate-order-fetch] page={page} size={page_size} rows={len(data.get("rows", []))} total={data.get("total")}' + ) + else: + print(f"[plate-order-fetch] page={page} size={page_size} received empty data") + if not data: + return [], 0 + + rows = data.get("rows", []) + total_count = data.get("total", 0) + return rows, total_count + + +async def sync_fabric_from_mingdaoyun(page: int = 1, page_size: int = 100) -> int: + """从明道云同步面料数据(用于创建/更新 QuickInput: 布料名)""" + + client = get_default_mingdaoyun_client() + response = await client.post( + endpoint=MDY_ENDPOINT_GET_FILTER_ROWS, + data={ + "worksheetId": MDY_WORKSHEET_ID_FABRIC, + "pageIndex": page, + "pageSize": page_size, + "sortId": "ctime", + "isAsc": False, + }, + ) + + data = response.get("data") if isinstance(response, dict) else None + if data: + rows = data.get("rows", []) + print(f"[fabric-sync] fetch rows={len(rows)} total={data.get('total')}") + if rows: + print(f"[fabric-sync] first row keys: {list(rows[0].keys())[:10]}") + print(f"[fabric-sync] first row sample: {rows[0]}") + else: + print(f"[fabric-sync] fetch empty data (page={page}, size={page_size})") + if not data: + return 0 + + fabrics = [pick_fabric(item) for item in data.get("rows", [])] + total_count = data.get("total", 0) + + await sync_to_async(_create_fabric_quick_inputs)( + page, + page_size, + fabrics, + ) + return total_count + + +def _create_fabric_quick_inputs(page: int, page_size: int, fabrics: list[Fabric]) -> None: + from basic_info import models as basic_models + + total = len(fabrics) + created_count = 0 + sample_names = [fabric.name for fabric in fabrics[:5]] + print(f"[fabric-sync] page={page} size={page_size} fetched={total}") + print(f"[fabric-sync] sample names: {sample_names}") + for fabric in fabrics: + if not fabric.name: + continue + obj, created = basic_models.QuickInput.objects.update_or_create( + name=fabric.name, + group="布料名", + defaults={"value": fabric.name}, + ) + if created: + created_count += 1 + print(f"[fabric-sync] page={page} new_records={created_count} updated_or_existing={total - created_count}") diff --git a/flower/utils/mingdaoyun/mappings.py b/flower/utils/mingdaoyun/mappings.py new file mode 100644 index 0000000..801f585 --- /dev/null +++ b/flower/utils/mingdaoyun/mappings.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Any, Dict + +# ------------------------------------------------------------------------------ +# 明道云:worksheetId 常量 +# ------------------------------------------------------------------------------ +MDY_WORKSHEET_ID_PRODUCT = "spmx" +MDY_WORKSHEET_ID_CUSTOMER = "quanbu" +MDY_WORKSHEET_ID_FABRIC = "668ba100fb551c850214067d" +MDY_WORKSHEET_ID_PLATE_ORDER = "668ba100fb551c8502140660" # 开版数据表 + +# 开版表关联数据 worksheetId(用于 Relation 关联查询) +MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING = "668ba100fb551c850214066b" # 画图 +MDY_WORKSHEET_ID_PLATE_ORDER_COLORING = "668ba100fb551c850214066c" # 调色 +MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET = "668ba100fb551c8502140681" # 套纸样 +MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING = "66e2eb5a3c1a53053f6d234d" # 改图 +MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME = "672adc61e72ce9924f763a0b" # 配色 +MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT = "66e2eee45d7e45f9c5ae41b4" # 照图开发 + + +mdy_table_map = { + "product": MDY_WORKSHEET_ID_PRODUCT, + "customer": MDY_WORKSHEET_ID_CUSTOMER, + "plate_order": MDY_WORKSHEET_ID_PLATE_ORDER, +} + +# 开版表关联数据表映射(后续做跨表查询时使用) +plate_order_related_worksheet_map = { + "drawing": MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING, + "coloring": MDY_WORKSHEET_ID_PLATE_ORDER_COLORING, + "pattern_set": MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET, + "modify_drawing": MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING, + "color_scheme": MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME, + "image_development": MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT, +} + +# 同上(中文 key,便于排查/对照) +plate_order_related_worksheet_map_cn = { + "画图": MDY_WORKSHEET_ID_PLATE_ORDER_DRAWING, + "调色": MDY_WORKSHEET_ID_PLATE_ORDER_COLORING, + "套纸样": MDY_WORKSHEET_ID_PLATE_ORDER_PATTERN_SET, + "改图": MDY_WORKSHEET_ID_PLATE_ORDER_MODIFY_DRAWING, + "配色": MDY_WORKSHEET_ID_PLATE_ORDER_COLOR_SCHEME, + "照图开发": MDY_WORKSHEET_ID_PLATE_ORDER_IMAGE_DEVELOPMENT, +} + + +# ------------------------------------------------------------------------------ +# 明道云:字段映射(内部字段名 -> controlId) +# ------------------------------------------------------------------------------ +product_type_map = { + "name": "668caa5eb80969563ecaee7d", + "pieces": "689c5aa37c175367b29fcb87", + "color": "66ed3b9ae01d5599bdb45f6d", + "uid": "668caa5eb80969563ecaee7c", + "unit": "668e2ee2ca758c8cc5d0dc1d", + "segment_size": "668e2194790c0e04058b4f3c", + "width": "668e2194790c0e04058b4f3c", + "detail_str": "668caa5eb80969563ecaee7e", + "created_at": "ctime", + "rowid": "rowid", +} + +customer_type_map = { + "name": "62d52f4b8d2972284492dcf9", + "area": "62d52f4b8d2972284492dd09", + "created_at": "ctime", + "rowid": "rowid", + "uid": "668bb9370207cf7520fe551e", +} + +fabric_type_map = { + "name": "62d52f4b8d2972284492de61", +} + + +# ------------------------------------------------------------------------------ +# 明道云:开版数据表(worksheetId: 668ba100fb551c8502140660) +# 字段说明(controlId -> 元信息)。按需求保留为 dict,暂不用于拉取请求。 +# ------------------------------------------------------------------------------ +plate_order_field_definitions: Dict[str, Dict[str, Any]] = { + # 成员 / 人员 + "62d8fffb625ac34fa91299c3": {"name": "调色", "type": "Collaborator"}, + # 关联记录(Relation)—— 展现 rowid + name + link + "62d52f4b8d2972284492dd28": {"name": "打版面料", "type": "Relation"}, + "62d52f4b8d2972284492dd14": {"name": "客户", "type": "Relation"}, + # 下拉/文本/数值/日期 + "62d52f4b8d2972284492dd15": {"name": "做货方式", "type": "Dropdown"}, + "62d52f4b8d2972284492dd0e": {"name": "设计编号", "type": "AutoNumber"}, + "62d91d0e130624d31368bd16": {"name": "开版方式", "type": "Dropdown"}, + "64a64bc4402bb5226ee34b3c": {"name": "等级", "type": "Rating"}, + "660b79da4125de62cd904b39": {"name": "布料", "type": "Dropdown"}, + "62db8e78510fb7962439b19c": {"name": "调色设计师", "type": "Text"}, + "62d52f4b8d2972284492dd10": {"name": "开发进程", "type": "Dropdown"}, + "62d52f4b8d2972284492dd1e": {"name": "是否套唛架", "type": "Dropdown"}, + "62d52f4b8d2972284492dd2c": {"name": "款号名称", "type": "Text"}, + "62d52f4b8d2972284492dd1d": {"name": "审批结果", "type": "Dropdown"}, + "62d52f4b8d2972284492dd12": {"name": "下版时间", "type": "DateTime"}, + "62d52f4b8d2972284492dd18": {"name": "复版原因", "type": "Dropdown"}, + "62d52f4b8d2972284492dd0f": {"name": "起版情况", "type": "Dropdown"}, + "62d52f4b8d2972284492dd19": {"name": "客户要求米样米数", "type": "Number"}, + "62d52f4b8d2972284492dd11": {"name": "紧急程度", "type": "Dropdown"}, + "660b75c3422f028085086b3c": {"name": "幅宽", "type": "Dropdown"}, + "62d52f4b8d2972284492dd13": {"name": "要求完成时间", "type": "Date"}, + "62f72a26d73f8581fbe7a488": {"name": "难度评级", "type": "Dropdown"}, + # 附件(Attachment)—— 展现文件名 + 下载链接 + "62d52f4b8d2972284492dd27": {"name": "开版图", "type": "Attachment"}, + # 评级/开关/组合字段 + "6451f4ad25766313eb8cb057": {"name": "日期", "type": "DateTime"}, + "66c2ebc8666ad6264b709fa1": {"name": "画图评级", "type": "Dropdown"}, + "66c2ebc8666ad6264b709fa2": {"name": "调色评级", "type": "Dropdown"}, + "66c2ebc8666ad6264b709fa3": {"name": "套样评级", "type": "Dropdown"}, + "6718ac791f08f8ec31257ebc": {"name": "记录id(文本组合)", "type": "Concatenate"}, + "67f4d7d61c5f650e952fcee9": {"name": "客户名称", "type": "Lookup"}, + # “有这个字段,但本条记录可能为空”的 Relation(占位:[]) + "62d52f4b8d2972284492dd2d": {"name": "套纸样", "type": "Relation"}, + "62d52f4b8d2972284492dd21": {"name": "画图", "type": "Relation"}, + "62d52f4b8d2972284492dd22": {"name": "调色(关联)", "type": "Relation"}, + "62d52f4b8d2972284492dd2a": {"name": "套纸样(关联)", "type": "Relation"}, + "66e2ebb7da66655f355bf708": {"name": "改图(关联)", "type": "Relation"}, + "66e2efb0da66655f355bf964": {"name": "找图开发", "type": "Relation"}, + "672adc9b156abb9a08ab2a60": {"name": "配色", "type": "Relation"}, +} + +# 同步代码风格:内部字段名 -> controlId(后续 pick / parse 时会用到) +plate_order_type_map = { + "colorist": "62d8fffb625ac34fa91299c3", + "fabric_relation": "62d52f4b8d2972284492dd28", + "customer_relation": "62d52f4b8d2972284492dd14", + "production_method": "62d52f4b8d2972284492dd15", + "design_no": "62d52f4b8d2972284492dd0e", + "plate_method": "62d91d0e130624d31368bd16", + "level_rating": "64a64bc4402bb5226ee34b3c", + "fabric_source": "660b79da4125de62cd904b39", + "color_designer": "62db8e78510fb7962439b19c", + "dev_progress": "62d52f4b8d2972284492dd10", + "need_marker_frame": "62d52f4b8d2972284492dd1e", + "style_name": "62d52f4b8d2972284492dd2c", + "approval_result": "62d52f4b8d2972284492dd1d", + "plate_time": "62d52f4b8d2972284492dd12", + "rework_reason": "62d52f4b8d2972284492dd18", + "start_plate_status": "62d52f4b8d2972284492dd0f", + "sample_meters": "62d52f4b8d2972284492dd19", # 对应样品米数(宇问云) + "urgency": "62d52f4b8d2972284492dd11", + "width": "660b75c3422f028085086b3c", + "required_finish_date": "62d52f4b8d2972284492dd13", + "difficulty_level": "62f72a26d73f8581fbe7a488", + "plate_images": "62d52f4b8d2972284492dd27", + "record_datetime": "6451f4ad25766313eb8cb057", + "drawing_rating": "66c2ebc8666ad6264b709fa1", + "color_rating": "66c2ebc8666ad6264b709fa2", + "pattern_fit_rating": "66c2ebc8666ad6264b709fa3", + "record_id": "6718ac791f08f8ec31257ebc", + "customer_name": "67f4d7d61c5f650e952fcee9", + # relations(可能为空) + "pattern_set": "62d52f4b8d2972284492dd2d", + "drawing_relation": "62d52f4b8d2972284492dd21", + "color_relation": "62d52f4b8d2972284492dd22", + "pattern_set_relation": "62d52f4b8d2972284492dd2a", + "modify_drawing_relation": "66e2ebb7da66655f355bf708", + "image_development_relation": "66e2efb0da66655f355bf964", + "color_scheme_relation": "672adc9b156abb9a08ab2a60", + # 系统字段 + "created_at": "ctime", + "rowid": "rowid", +} diff --git a/flower/utils/mingdaoyun/models.py b/flower/utils/mingdaoyun/models.py new file mode 100644 index 0000000..1e844d3 --- /dev/null +++ b/flower/utils/mingdaoyun/models.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +from typing import Any, List + +from pydantic import BaseModel, Field, computed_field + + +class Product(BaseModel): + """产品模型""" + + uid: str + rowid: str + name: str + pieces: int | None + segment_size: int | None + unit: str | None + color: str | None + width: str | None = None + detail_str: str | None = Field(exclude=True) # 在序列化时排除此字段 + created_at: str + + @computed_field + @property + def detail(self) -> dict[str, Any]: + """将 detail_str 转换为字典""" + + try: + return json.loads(self.detail_str) if self.detail_str else {} + except (json.JSONDecodeError, TypeError): + return {} + + +class ProductListResponse(BaseModel): + """产品列表响应""" + + products: List[Product] + total: int + + +class Customer(BaseModel): + """客户模型""" + + uid: str + rowid: str + name: str + area: str | None + created_at: str + + +class Fabric(BaseModel): + """面料模型""" + + name: str + + +# ------------------------------------------------------------------------------ +# 明道云:通用值类型(Relation / Attachment / Collaborator 等) +# ------------------------------------------------------------------------------ +class MDYRelationItem(BaseModel): + """Relation 字段的单项(跨表关联)""" + + rowid: str + name: str + link: str | None = None + + +class MDYAttachmentItem(BaseModel): + """Attachment 字段的单项(附件)""" + + original_file_name: str + file_id: str + download_url: str + preview_url: str | None = None + + +class MDYCollaboratorItem(BaseModel): + """Collaborator 字段的单项(成员/人员) + + 说明:明道云在不同场景下可能返回对象或列表;这里先按“对象字段”建模, + 后续解析时可以按实际返回结构再做兼容适配。 + """ + + accountId: str | None = None + fullname: str | None = None + avatar: str | None = None + + +class MDYPlateOrder(BaseModel): + """开版数据表(明道云)的一行记录:仅做类型定义""" + + rowid: str + created_at: str + + # 成员 / 人员 + colorist: MDYCollaboratorItem | List[MDYCollaboratorItem] | str | None = None + + # Relation(跨表关联) + fabric_relation: List[MDYRelationItem] = Field(default_factory=list) + customer_relation: List[MDYRelationItem] = Field(default_factory=list) + + # 基础字段 + production_method: str | None = None + design_no: str | None = None + plate_method: str | None = None + level_rating: int | None = None + fabric_source: str | None = None + color_designer: str | None = None + dev_progress: str | None = None + need_marker_frame: str | None = None + style_name: str | None = None + approval_result: str | None = None + + plate_time: str | None = None # DateTime: 'YYYY-MM-DD HH:MM:SS' + required_finish_date: str | None = None # Date: 'YYYY-MM-DD' + record_datetime: str | None = None # DateTime: 'YYYY-MM-DD HH:MM:SS' + + rework_reason: str | None = None + start_plate_status: str | None = None + sample_meters: int | float | None = None + urgency: str | None = None + width: str | None = None + difficulty_level: str | None = None + + # 附件 + plate_images: List[MDYAttachmentItem] = Field(default_factory=list) + + # 评级/组合字段 + drawing_rating: str | None = None + color_rating: str | None = None + pattern_fit_rating: str | None = None + record_id: str | None = None + customer_name: str | None = None + + # 其它 Relation(可能为空) + pattern_set: List[MDYRelationItem] = Field(default_factory=list) + drawing_relation: List[MDYRelationItem] = Field(default_factory=list) + color_relation: List[MDYRelationItem] = Field(default_factory=list) + pattern_set_relation: List[MDYRelationItem] = Field(default_factory=list) + modify_drawing_relation: List[MDYRelationItem] = Field(default_factory=list) + image_development_relation: List[MDYRelationItem] = Field(default_factory=list) + color_scheme_relation: List[MDYRelationItem] = Field(default_factory=list) diff --git a/flower/utils/mingdaoyun/parsers.py b/flower/utils/mingdaoyun/parsers.py new file mode 100644 index 0000000..b780f1c --- /dev/null +++ b/flower/utils/mingdaoyun/parsers.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +from .mappings import customer_type_map, fabric_type_map, product_type_map +from .models import Customer, Fabric, Product + + +def pick_customer(fields: dict[str, Any]) -> Customer: + """从字段字典中提取客户信息""" + + data = {k: fields.get(v, "") for k, v in customer_type_map.items()} + return Customer(**data) + + +def pick_product(fields: dict[str, Any]) -> Product: + """从字段字典中提取产品信息""" + + data = {k: fields.get(v, "") for k, v in product_type_map.items()} + + def _to_int(value): + if value in ("", None): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + data["pieces"] = _to_int(data.get("pieces")) + data["segment_size"] = _to_int(data.get("segment_size")) + + return Product(**data) + + +def pick_fabric(fields: dict[str, Any]) -> Fabric: + """从字段字典中提取面料信息""" + + data = {k: fields.get(v, "") for k, v in fabric_type_map.items()} + return Fabric(**data) diff --git a/stateflow/services.py b/stateflow/services.py index b333657..7e92639 100644 --- a/stateflow/services.py +++ b/stateflow/services.py @@ -175,7 +175,40 @@ def advance_to_next_state(business_object: 'models.BusinessObject', user, **para - state_log: 创建的状态日志(成功时) """ # 业务约束:BusinessObject 必须能追溯到真实业务对象,否则状态流转记录没有业务意义。 - # DB 允许为空仅为历史兼容;但新增数据一律禁止。 + # DB 允许为空仅为历史兼容;但实际运行中存在“先创建 BO、后绑定到业务模型”的非标准路径。 + # 为避免这类数据导致推进失败,这里做一次自愈: + # - 若 BO 未绑定 content_type/object_id(或 content_object 解析不到实例) + # - 则尝试通过常见的一对一反向关系(如 printing_job/plate_order)推断真实业务对象并补齐绑定 + if business_object.content_type_id is None or business_object.object_id is None or business_object.content_object is None: + inferred_instance = None + inferred_default_name = None + + # PrintingJob.business_object -> related_name='printing_job' + try: + inferred_instance = getattr(business_object, 'printing_job', None) + if inferred_instance is not None: + inferred_default_name = f"PrintingJob-{getattr(inferred_instance, 'pk', '')}" + except (ObjectDoesNotExist, AttributeError): + inferred_instance = None + + # PlateOrder.business_object -> related_name='plate_order' + if inferred_instance is None: + try: + inferred_instance = getattr(business_object, 'plate_order', None) + if inferred_instance is not None: + inferred_default_name = f"PlateOrder-{getattr(inferred_instance, 'pk', '')}" + except (ObjectDoesNotExist, AttributeError): + inferred_instance = None + + if inferred_instance is not None: + ensure_business_object_bound_to_instance( + business_object, + inferred_instance, + default_name=inferred_default_name, + ) + # GenericForeignKey 可能缓存了旧值,刷新以确保后续 content_object 判断正确 + business_object.refresh_from_db(fields=['content_type', 'object_id', 'name']) + if business_object.content_type_id is None or business_object.object_id is None: return False, "业务对象未绑定关联对象(content_type/object_id),禁止推进", None if business_object.content_object is None: