diff --git a/init/data.json b/init/data.json index 07f7341..6f58fe6 100644 --- a/init/data.json +++ b/init/data.json @@ -64,6 +64,11 @@ "id": "billing_mode", "name": "计费模式", "hierarchy_flg": "0" + }, + { + "id": "resource_module", + "name": "资源模块", + "hierarchy_flg": "0" } ], "appcodes_kv": [ @@ -117,7 +122,10 @@ {"id": "overflow_mode_2", "parentid": "overflow_mode", "k": "2", "v": "停服"}, {"id": "billing_mode_1", "parentid": "billing_mode", "k": "1", "v": "配额内"}, - {"id": "billing_mode_2", "parentid": "billing_mode", "k": "2", "v": "超额按量"} + {"id": "billing_mode_2", "parentid": "billing_mode", "k": "2", "v": "超额按量"}, + + {"id": "resource_module_llmage", "parentid": "resource_module", "k": "llmage", "v": "大模型管理"}, + {"id": "resource_module_supplychain", "parentid": "resource_module", "k": "supplychain", "v": "供应链管理"} ], "_note_product_category": "产品类别树由每个 reseller (org_id) 自行管理,不在 init/data.json 中预设全局数据。新机构注册时自动创建根类别。" } diff --git a/json/product_category_tree.json b/json/product_category_tree.json index b1edb98..dca3796 100644 --- a/json/product_category_tree.json +++ b/json/product_category_tree.json @@ -16,6 +16,32 @@ "edit_exclouded_fields": ["created_by", "created_at", "updated_at", "org_id", "product_type_title"], "logined_userorgid": "org_id", "browserfields": {}, + "toolbar": { + "tools": [ + { + "name": "import_products", + "label": "添加子产品类和产品导入", + "selected_data": true + } + ] + }, + "binds": [ + { + "wid": "self", + "event": "import_products", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": { + "title": "产品导入结果", + "cwidth": 20, + "cheight": 10 + }, + "options": { + "url": "{{entire_url('../api/import_category_products.dspy')}}", + "params": {"category_id": "${id}$"} + } + } + ], "subtables": [ { "field": "category_id", diff --git a/models/product_category.json b/models/product_category.json index 66b39f6..6c33758 100644 --- a/models/product_category.json +++ b/models/product_category.json @@ -55,6 +55,12 @@ "type": "str", "length": 255 }, + { + "name": "resource_module", + "title": "资源模块", + "type": "str", + "length": 64 + }, { "name": "sort_order", "title": "排序序号", @@ -154,6 +160,13 @@ "valuefield": "k", "textfield": "v", "cond": "parentid='product_type'" + }, + { + "field": "resource_module", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='resource_module'" } ] } diff --git a/product_management/__init__.py b/product_management/__init__.py index c529105..aab9c9a 100644 --- a/product_management/__init__.py +++ b/product_management/__init__.py @@ -67,6 +67,14 @@ async def set_operator_config(category_id, config_name, config_json, user_id=Non return await manager.set_operator_config(category_id, config_name, config_json, user_id, org_id) +async def import_categories_and_products(org_id, parent_category_id, user_id, import_data): + """Generic import engine for sub-categories and products.""" + manager = get_manager() + return await manager.import_categories_and_products( + org_id, parent_category_id, user_id, import_data + ) + + def load_product_management(): """Register all functions with ServerEnv so they can be called from .ui/.dspy files.""" env = ServerEnv() @@ -78,4 +86,5 @@ def load_product_management(): env.get_products_by_category = get_products_by_category env.get_operator_config = get_operator_config env.set_operator_config = set_operator_config + env.import_categories_and_products = import_categories_and_products return True diff --git a/product_management/core.py b/product_management/core.py index c28523b..a887d16 100644 --- a/product_management/core.py +++ b/product_management/core.py @@ -1090,3 +1090,128 @@ class ProductManager: 'overflow_mode': sub.get('overflow_mode'), 'overflow_rate': float(sub.get('overflow_rate', 0)) } + + # ─── Product Import from Resource Modules ─── + + async def import_categories_and_products(self, org_id, parent_category_id, + user_id, import_data): + """Generic import engine: create sub-categories and products from structured data. + + import_data format: + { + 'categories': [ + {'name': '子类别名', 'source_id': 'ext_id', 'parent_source_id': None, + 'sort_order': 1, 'description': '', 'has_product': '1', 'product_type': 'llm_model'}, + ... + ], + 'products': [ + {'product_code': 'code1', 'product_name': '产品1', + 'category_source_id': 'ext_id', 'product_type': 'llm_model', + 'brief_intro': '', 'price': 0, 'currency': 'CNY', 'sort_order': 0}, + ... + ] + } + + source_id is the external module's ID, used to avoid duplicates. + Products link to categories via category_source_id matching a category's source_id. + """ + import time + dbname = self._get_dbname() + now = time.strftime('%Y-%m-%d %H:%M:%S') + created_cats = 0 + skipped_cats = 0 + created_prods = 0 + skipped_prods = 0 + + async with DBPools().sqlorContext(dbname) as sor: + # Phase 1: Create sub-categories under parent_category_id + # Map source_id -> new category id + source_to_id = {} + + for cat in import_data.get('categories', []): + source_id = cat.get('source_id', '') + # Check if already imported (by name + parent) + existing = await sor.sqlExe( + """SELECT id FROM product_category + WHERE name = ${name}$ AND parent_id = ${parent_id}$ AND org_id = ${org_id}$""", + {'name': cat['name'], 'parent_id': parent_category_id, 'org_id': org_id} + ) + if existing: + source_to_id[source_id] = existing[0]['id'] + skipped_cats += 1 + continue + + new_id = getID() + source_to_id[source_id] = new_id + # Determine parent: if parent_source_id is set, use that mapping + psrc = cat.get('parent_source_id') + actual_parent = source_to_id.get(psrc, parent_category_id) if psrc else parent_category_id + + cat_data = { + 'id': new_id, + 'parent_id': actual_parent, + 'name': cat['name'], + 'description': cat.get('description', ''), + 'has_product': cat.get('has_product', '1'), + 'product_type': cat.get('product_type', ''), + 'product_type_title': cat.get('product_type_title', ''), + 'sort_order': str(cat.get('sort_order', 0)), + 'icon': cat.get('icon', ''), + 'status': '1', + 'resource_module': cat.get('resource_module', ''), + 'org_id': org_id, + 'created_by': user_id, + 'created_at': now, + 'updated_at': now + } + await sor.C('product_category', cat_data) + created_cats += 1 + + # Phase 2: Create products under the mapped categories + for prod in import_data.get('products', []): + cat_source_id = prod.get('category_source_id', '') + target_cat_id = source_to_id.get(cat_source_id) + if not target_cat_id: + skipped_prods += 1 + continue + + # Check duplicate by product_code + org + existing_prod = await sor.sqlExe( + """SELECT id FROM product + WHERE product_code = ${code}$ AND org_id = ${org_id}$""", + {'code': prod['product_code'], 'org_id': org_id} + ) + if existing_prod: + skipped_prods += 1 + continue + + prod_id = getID() + prod_data = { + 'id': prod_id, + 'category_id': target_cat_id, + 'product_code': prod['product_code'], + 'product_name': prod['product_name'], + 'product_type': prod.get('product_type', ''), + 'brief_intro': prod.get('brief_intro', ''), + 'detail_intro': prod.get('detail_intro', ''), + 'extra_json': prod.get('extra_json', ''), + 'enabled_date': prod.get('enabled_date'), + 'expired_date': prod.get('expired_date'), + 'status': prod.get('status', '1'), + 'price_type': prod.get('price_type', '1'), + 'price': str(prod.get('price', 0)), + 'currency': prod.get('currency', 'CNY'), + 'sort_order': str(prod.get('sort_order', 0)), + 'org_id': org_id, + 'created_by': user_id, + 'created_at': now, + 'updated_at': now + } + await sor.C('product', prod_data) + created_prods += 1 + + return { + 'success': True, + 'message': f'导入完成: 新增 {created_cats} 个子类别, {created_prods} 个产品; ' + f'跳过 {skipped_cats} 个已存在类别, {skipped_prods} 个已存在产品' + } diff --git a/scripts/load_path.py b/scripts/load_path.py index 9805ac9..c286587 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -119,6 +119,9 @@ PATHS_LOGINED = [ f"/{MOD}/api/product_resource_supplier_update.dspy", f"/{MOD}/api/product_resource_supplier_delete.dspy", + # Import + f"/{MOD}/api/import_category_products.dspy", + # Subscriptions f"/{MOD}/product_subscription_list", f"/{MOD}/product_subscription_list/index.ui", diff --git a/wwwroot/api/import_category_products.dspy b/wwwroot/api/import_category_products.dspy new file mode 100644 index 0000000..667ccb7 --- /dev/null +++ b/wwwroot/api/import_category_products.dspy @@ -0,0 +1,45 @@ +result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}} + +try: + category_id = params_kw.get('category_id', '') + if not category_id: + raise ValueError('请先选择一个产品类别') + + org_id = (await get_userorgid()) or '0' + user_id = await get_user() + dbname = get_module_dbname('product_management') + + # Get the selected category's resource_module + async with DBPools().sqlorContext(dbname) as sor: + rows = await sor.sqlExe( + "SELECT id, name, resource_module, org_id FROM product_category WHERE id = ${id}$ AND org_id = ${org_id}$", + {'id': category_id, 'org_id': org_id} + ) + if not rows: + raise ValueError('类别不存在或无权操作') + cat = rows[0] + + resource_module = cat.resource_module + if not resource_module: + raise ValueError('该类别未配置资源模块,请先在类别编辑中设置资源模块') + + # Look up the registered import function by module name + env = request._run_ns + import_fn_name = f'import_products_from_{resource_module}' + import_fn = getattr(env, import_fn_name, None) + if import_fn is None: + raise ValueError(f'资源模块 "{resource_module}" 未注册产品导入功能') + + # Call the resource module's import function + import_result = await import_fn(org_id=org_id, parent_category_id=category_id, user_id=user_id) + + if import_result.get('success'): + msg = import_result.get('message', '导入完成') + result = {'widgettype': 'Message', 'options': {'title': '导入成功', 'message': msg, 'type': 'success', 'timeout': 5}} + else: + raise ValueError(import_result.get('error', '导入失败')) + +except Exception as e: + result['options'] = {'title': '导入失败', 'message': str(e), 'type': 'error', 'timeout': 5} + +return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/product_category_tree/index.ui b/wwwroot/product_category_tree/index.ui index 018fe81..ce66987 100644 --- a/wwwroot/product_category_tree/index.ui +++ b/wwwroot/product_category_tree/index.ui @@ -11,7 +11,7 @@ - "toolbar":{"tools":[{"selected_row":true,"name":"product","icon":"{{entire_url('/imgs/product.svg')}}","label":"下属产品"}]}, + "toolbar":{"tools":[{"selected_row":true,"name":"product","icon":"{{entire_url('/imgs/product.svg')}}","label":"下属产品"},{"name":"import_products","label":"添加子产品类和产品导入","selected_data":true}]}, "editable":{ @@ -147,6 +147,26 @@ "text": "禁用" } ] + }, + { + "name": "resource_module", + "title": "资源模块", + "type": "str", + "length": 64, + "label": "资源模块", + "uitype": "code", + "valueField": "resource_module", + "textField": "resource_module_text", + "params": { + "dbname": "{{get_module_dbname('product_management')}}", + "table": "appcodes_kv", + "tblvalue": "k", + "tbltext": "v", + "valueField": "resource_module", + "textField": "resource_module_text", + "cond": "id='resource_module'" + }, + "dataurl": "{{entire_url('/appbase/get_code.dspy')}}" } ], @@ -198,6 +218,21 @@ "params": {}, "url": "{{entire_url('../product_list')}}" } + }, + { + "wid": "self", + "event": "import_products", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": { + "title": "产品导入结果", + "cwidth": 20, + "cheight": 10 + }, + "options": { + "url": "{{entire_url('../api/import_category_products.dspy')}}", + "params": {"category_id": "${id}$"} + } } ]