feat: add resource_module field and import sub-categories/products tool

- Add resource_module field to product_category model (stores module name like 'llmage')
- Add resource_module appcode with llmage/supplychain entries to init/data.json
- Add '添加子产品类和产品导入' toolbar tool to tree CRUD (json + generated index.ui)
- Create import_category_products.dspy API endpoint
- Add import_categories_and_products() generic import engine to core.py
- Register import function on ServerEnv
- Update load_path.py with new API endpoint
This commit is contained in:
Hermes Agent 2026-06-23 13:47:05 +08:00
parent e8f836ba2d
commit 0f321b8185
8 changed files with 266 additions and 2 deletions

View File

@ -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 中预设全局数据。新机构注册时自动创建根类别。"
}

View File

@ -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",

View File

@ -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'"
}
]
}

View File

@ -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

View File

@ -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} 个已存在产品'
}

View File

@ -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",

View File

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

View File

@ -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}$"}
}
}
]