feat(mining-ui): P2需求挖掘三级下钻UI——⛏需求挖掘菜单(无require_project,平台级批次也可见)→批次列表(启动全量/指定类型按钮+状态色标+done行下钻)→类别排名PopupWindow(排名/占比/命名依据)→类内明细(每条带来源,src_id复用opp_item_detail详情弹窗);opp_mining_start.dspy复用start_mining与工具通道同实现;cluster_detail补src_id字段;load_path注册4端点

This commit is contained in:
yumoqing 2026-09-12 11:00:49 +08:00
parent 3fadee97b9
commit 25348fa2e1
7 changed files with 472 additions and 1 deletions

View File

@ -550,6 +550,8 @@ def register_opp_ability():
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui",
"type": "popup", "width": "88%", "height": "82%", "require_project": True},
{"label": "⛏ 需求挖掘", "icon": "", "url": "/pipeline-opportunity/api/opp_mining_popup.dspy",
"type": "popup", "width": "76%", "height": "82%"},
{"label": "📋 数据参考", "icon": "", "url": "/pipeline-opportunity/api/opp_references_popup.dspy",
"type": "popup", "width": "70%", "height": "76%", "require_project": True},
],

View File

@ -280,7 +280,7 @@ async def cluster_detail(sor, ctx, cluster_id, limit=20):
if not ok:
return False, err
items = rows_to_dicts(await sor.sqlExe(
"SELECT id, title, source, budget_wan, url, publish_time FROM opp_demand_snap "
"SELECT id, src_id, title, source, budget_wan, url, publish_time FROM opp_demand_snap "
"WHERE cluster_id=${c}$ ORDER BY budget_wan DESC LIMIT ${n}$",
{"c": cluster_id, "n": int(limit)}), limit=int(limit))
await sor.sqlExe("COMMIT", {})

View File

@ -35,6 +35,10 @@ PATHS_LOGINED = [
"/%s/api/opp_reports_list.dspy" % MOD,
"/%s/api/opp_report_detail.dspy" % MOD,
"/%s/api/opp_report_file_dl.dspy" % MOD,
"/%s/api/opp_mining_popup.dspy" % MOD,
"/%s/api/opp_mining_start.dspy" % MOD,
"/%s/api/opp_mining_clusters.dspy" % MOD,
"/%s/api/opp_mining_items.dspy" % MOD,
]
for t in TABLES:
PATHS_LOGINED += [

View File

@ -0,0 +1,116 @@
# opp_mining_clusters.dspy - 挖掘批次的类别排名(三级下钻第二级)
# 入参batch_id。可见性规则源 = opp_mining_flow.list_clusters含批次项目/org 校验)。
# 整行点击 → opp_mining_items.dspy 弹出类内需求明细第三级每条带来源URL
# 弹窗容器 id=opp_mine_clusters_pw与列表页下钻脚本约定一致
import json as _json
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
batch_id = ((params_kw or {}).get("batch_id") or "").strip()
if not batch_id:
return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 batch_id"}}
dbname = get_module_dbname('pipeline-opportunity')
session_id = (params_kw or {}).get('session_id', '') or ''
pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general'
_cur_pid = ''
_uorg = ''
try:
from pipeline_service.workspace import get_session_project_id
async with DBPools().sqlorContext(dbname) as sor:
_cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or ''
_urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid})
await sor.sqlExe("COMMIT", {})
if _urecs:
_uorg = str(getattr(_urecs[0], "orgid", "") or "")
except Exception:
pass
_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg,
"pipeline_id": pipeline_id, "session_id": session_id}
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_mining_flow import list_clusters
_ok, _res = await list_clusters(sor, _ctx, batch_id)
await sor.sqlExe("COMMIT", {})
items_url = entire_url("/pipeline-opportunity/api/opp_mining_items.dspy")
rows = []
if not _ok:
rows.append({"widgettype": "Text", "options": {
"text": str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
clusters = _res if isinstance(_res, list) else []
if not clusters:
rows.append({"widgettype": "Text", "options": {
"text": "该批次没有聚类结果(可能范围内无需求数据)",
"color": "#64748b", "padding": "12px", "halign": "left"}})
for c in clusters:
cid = str(c.get("id") or "")
name = str(c.get("name") or "未命名")
cnt = int(c.get("doc_count") or 0)
share = float(c.get("share") or 0) * 100
rank = int(c.get("heat_rank") or 0)
try:
ev = _json.loads(c.get("naming_evidence") or "{}")
except Exception:
ev = {}
samples = ev.get("samples") or []
ev_txt = ("命名依据:" + " / ".join(str(s)[:24] for s in samples[:3])) if samples else ""
by_txt = "(规则命名)" if ev.get("by") == "rule" else ""
q_extra = []
if session_id:
q_extra.append("session_id=" + session_id)
if pipeline_id:
q_extra.append("pipeline_id=" + pipeline_id)
qstr = ("&" + "&".join(q_extra)) if q_extra else ""
script = ("var old=bricks.getWidgetById('opp_mine_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var r=await fetch(" + _json.dumps(items_url) + "+'?cluster_id='+encodeURIComponent(" + _json.dumps(cid) + ")+" + _json.dumps(qstr) + ");"
"var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}")
rows.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "10px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff",
"cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
"subwidgets": [
{"widgettype": "Text", "options": {"text": "#%d" % rank, "cfontsize": 0.85,
"color": "#94a3b8"}},
{"widgettype": "VBox", "options": {"gap": "2px"}, "subwidgets": [
{"widgettype": "Text", "options": {"text": name + by_txt, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Text", "options": {"text": ev_txt, "cfontsize": 0.72,
"color": "#94a3b8", "halign": "left",
"wrap": True}},
]},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条 %.1f%%" % (cnt, share),
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Text", "options": {"text": "", "cfontsize": 1.1, "color": "#94a3b8"}},
]})
return {
"id": "opp_mine_clusters_pw",
"widgettype": "PopupWindow",
"options": {"title": "类别排名(批次 %s" % batch_id[:8], "width": "640px",
"height": "70%", "modal": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "12px 16px",
"gap": "4px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。",
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
] + rows,
}],
}

View File

@ -0,0 +1,120 @@
# opp_mining_items.dspy - 挖掘类别的类内需求明细(三级下钻第三级)
# 入参cluster_id。可见性经 opp_mining_flow.cluster_detail含批次项目/org 校验)。
# 每条带来源source_text 说明文字),整行点击 → opp_item_detail.dspy爬虫平台详情弹窗
# 快照 src_id 即爬虫平台 tenders.id直接复用既有详情通道
import json as _json
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
cluster_id = ((params_kw or {}).get("cluster_id") or "").strip()
if not cluster_id:
return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 cluster_id"}}
try:
limit = int((params_kw or {}).get("limit") or 50)
except (TypeError, ValueError):
limit = 50
dbname = get_module_dbname('pipeline-opportunity')
session_id = (params_kw or {}).get('session_id', '') or ''
pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general'
_cur_pid = ''
_uorg = ''
try:
from pipeline_service.workspace import get_session_project_id
async with DBPools().sqlorContext(dbname) as sor:
_cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or ''
_urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid})
await sor.sqlExe("COMMIT", {})
if _urecs:
_uorg = str(getattr(_urecs[0], "orgid", "") or "")
except Exception:
pass
_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg,
"pipeline_id": pipeline_id, "session_id": session_id}
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_mining_flow import cluster_detail
from pipeline_opportunity.opp_common import source_text
_ok, _res = await cluster_detail(sor, _ctx, cluster_id, limit=limit)
await sor.sqlExe("COMMIT", {})
detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy")
rows = []
if not _ok:
rows.append({"widgettype": "Text", "options": {
"text": str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}})
cl_name, cl_cnt = "", 0
else:
cl = _res.get("cluster") or {}
cl_name = str(cl.get("name") or "")
cl_cnt = int(cl.get("doc_count") or 0)
items = _res.get("samples") or []
if not items:
rows.append({"widgettype": "Text", "options": {
"text": "该类别暂无明细", "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
src_id = str(it.get("src_id") or "")
title = str(it.get("title") or "")
budget = it.get("budget_wan") or 0
url = str(it.get("url") or "")
src_name = source_text(str(it.get("source") or ""), "")
meta = "%s %s 来源:%s" % (
str(it.get("publish_time") or "")[:10],
(("预算 %.2f 万" % float(budget)) if budget else "预算面议"),
src_name)
row_subs = [
{"widgettype": "Text", "options": {"text": title, "cfontsize": 0.95,
"color": "#0f172a", "halign": "left", "wrap": True}},
{"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": meta, "cfontsize": 0.75,
"color": "#94a3b8", "halign": "left", "wrap": True}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "", "cfontsize": 1.0, "color": "#94a3b8"}},
]},
]
row_opts = {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"}
if src_id:
row_opts["cursor"] = "pointer"
script = ("var old=bricks.getWidgetById('opp_item_detail_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(detail_url) + "+'?id='+encodeURIComponent(" + _json.dumps(src_id) + "));"
"var d=await rp.json();if(d){bricks.widgetBuild(d,bricks.app);}")
rows.append({"widgettype": "VBox", "options": row_opts,
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
"subwidgets": row_subs})
elif url:
row_opts["cursor"] = "pointer"
script = "window.open(" + _json.dumps(url) + ",'_blank');"
rows.append({"widgettype": "VBox", "options": row_opts,
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}],
"subwidgets": row_subs})
else:
rows.append({"widgettype": "VBox", "options": row_opts, "subwidgets": row_subs})
return {
"widgettype": "PopupWindow",
"id": "opp_mine_items_pw",
"options": {"title": "类内需求明细:%s%d 条,显示前 %d" % (cl_name, cl_cnt, limit),
"width": "76%", "height": "80%", "auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。",
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}},
] + rows,
}],
}

View File

@ -0,0 +1,181 @@
# opp_mining_popup.dspy - 需求挖掘批次列表(「⛏ 需求挖掘」菜单入口P2 三级下钻第一级)
# 交互范式对齐 opp_reports_list.dspy会话当前项目过滤本项目批次 + 平台级批次),
# 整行点击 → opp_mining_clusters.dspy 弹出类别排名(第二级)。
# 顶部两个启动按钮全量挖掘scope=top/ 指定类型挖掘scope=targetedprompt 输入关键词)。
# 可见性规则源 = opp_mining_flow.mining_status与工具通道同一实现语义不漂移
import json as _json
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
dbname = get_module_dbname('pipeline-opportunity')
session_id = (params_kw or {}).get('session_id', '') or ''
pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general'
# 会话当前项目统一解析session→全局兜底+产线隔离+悬空自愈)
_cur_pid = ''
try:
from pipeline_service.workspace import get_session_project_id
async with DBPools().sqlorContext(dbname) as sor:
_cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or ''
except Exception:
_cur_pid = ''
# 用户机构org 隔离)
_uorg = ''
async with DBPools().sqlorContext(dbname) as sor:
_urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid})
await sor.sqlExe("COMMIT", {})
if _urecs:
_uorg = str(getattr(_urecs[0], "orgid", "") or "")
_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg,
"pipeline_id": pipeline_id, "session_id": session_id}
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_mining_flow import mining_status
_ok, _res = await mining_status(sor, _ctx, batch_id="")
await sor.sqlExe("COMMIT", {})
clusters_url = entire_url("/pipeline-opportunity/api/opp_mining_clusters.dspy")
start_url = entire_url("/pipeline-opportunity/api/opp_mining_start.dspy")
self_url = entire_url("/pipeline-opportunity/api/opp_mining_popup.dspy")
_q = []
if session_id:
_q.append("session_id=" + session_id)
if pipeline_id:
_q.append("pipeline_id=" + pipeline_id)
_qs = ("?" + "&".join(_q)) if _q else ""
_STATUS_STYLE = {
"done": ("已完成", "#16a34a", "#f0fdf4"),
"failed": ("失败", "#dc2626", "#fef2f2"),
"pulling": ("拉取中", "#d97706", "#fffbeb"),
"embedding": ("向量化中", "#d97706", "#fffbeb"),
"clustering": ("聚类中", "#d97706", "#fffbeb"),
"naming": ("命名中", "#d97706", "#fffbeb"),
}
def _refresh_script():
return ("var r=await fetch(" + _json.dumps(self_url + _qs) + ");"
"var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}")
def _start_script(scope):
# targeted 先 prompt 关键词;启动后轮询刷新列表
kw_js = ("var kw=prompt('输入要挖掘的需求类型关键词(如:合同管理)');if(!kw){return;}kw='&keyword='+encodeURIComponent(kw);") if scope == "targeted" else "var kw='';"
base = start_url + _qs + ("&" if _q else "?") + "scope=" + scope
return (kw_js +
"var r=await fetch(" + _json.dumps(base) + "+kw);"
"var d=await r.json();"
"if(!d||d.status!=='ok'){alert((d&&d.message)||'启动失败');return;}"
"alert('挖掘批次已启动:'+d.batch_id+'(后台执行,列表将自动刷新)');" +
_refresh_script())
rows = []
# ── 顶部:启动按钮 + 说明 ──
rows.append({"widgettype": "Text", "options": {
"text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH的软件需求做语义聚类"
"发现市场热门软件类别TopX或分析你指定的需求类型。批次后台执行完成后点击行查看类别排名。",
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "wrap": True, "margin": "0 0 8px 0"}})
rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "margin": "0 0 12px 0"},
"subwidgets": [
{"widgettype": "Button", "options": {"label": "⛏ 启动全量挖掘TopX 热门类别)", "css": "primary"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": _start_script("top")}]},
{"widgettype": "Button", "options": {"label": "🎯 指定类型挖掘", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": _start_script("targeted")}]},
{"widgettype": "Filler"},
{"widgettype": "Button", "options": {"label": "🔄 刷新", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": _refresh_script()}]},
]})
if not _ok:
rows.append({"widgettype": "Text", "options": {
"text": "查询失败:" + str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
batches = _res if isinstance(_res, list) else []
if not batches:
rows.append({"widgettype": "Text", "options": {
"text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始首次全量约3-5分钟"
"embedding 有全机构共享缓存,二次批次显著更快)。",
"color": "#64748b", "padding": "12px", "halign": "left", "wrap": True}})
for b in batches:
bid = str(b.get("id") or "")
status = str(b.get("status") or "")
s_label, s_color, s_bg = _STATUS_STYLE.get(status, (status, "#64748b", "#f8fafc"))
scope = str(b.get("scope") or "")
scope_txt = "全量 TopX" if scope == "top" else "指定类型"
try:
_params = _json.loads(b.get("params_json") or "{}")
except Exception:
_params = {}
if scope == "targeted":
kw = _params.get("keyword") or _params.get("category") or ""
if kw:
scope_txt += "%s" % kw
try:
_stats = _json.loads(b.get("stats_json") or "{}")
except Exception:
_stats = {}
meta_bits = []
if _stats.get("demands"):
meta_bits.append("需求 %s 条" % _stats["demands"])
if _stats.get("clusters"):
meta_bits.append("类别 %s 个" % _stats["clusters"])
if _stats.get("elapsed_s"):
meta_bits.append("耗时 %ss" % _stats["elapsed_s"])
if not str(b.get("project_id") or ""):
meta_bits.append("平台级")
if status == "failed" and b.get("error_msg"):
meta_bits.append("原因:" + str(b.get("error_msg"))[:80])
meta = " ".join(meta_bits) if meta_bits else str(b.get("created_at") or "")[:16]
# done 批次整行可点下钻;进行中/失败批次不可点(无结果可看)
if status == "done":
qstr = ("&" + "&".join(_q)) if _q else ""
script = ("var old=bricks.getWidgetById('opp_mine_clusters_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var r=await fetch(" + _json.dumps(clusters_url) + "+'?batch_id='+encodeURIComponent(" + _json.dumps(bid) + ")+" + _json.dumps(qstr) + ");"
"var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}")
row_opts = {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "10px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff",
"cursor": "pointer"}
row_binds = [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]
arrow = {"widgettype": "Text", "options": {"text": "", "cfontsize": 1.1, "color": "#94a3b8"}}
else:
row_opts = {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "10px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#f8fafc"}
row_binds = []
arrow = None
subs = [
{"widgettype": "Text", "options": {"text": scope_txt, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Text", "options": {"text": s_label, "cfontsize": 0.75,
"color": s_color, "bgcolor": s_bg,
"padding": "2px 8px", "borderRadius": "10px"}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": meta, "cfontsize": 0.75,
"color": "#94a3b8", "halign": "right", "wrap": True}},
]
if arrow:
subs.append(arrow)
row = {"widgettype": "HBox", "options": row_opts, "subwidgets": subs}
if row_binds:
row["binds"] = row_binds
rows.append(row)
return {
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%", "padding": "12px 16px",
"gap": "6px", "overflow": "auto"},
"subwidgets": rows,
}

View File

@ -0,0 +1,48 @@
# opp_mining_start.dspy - 启动需求挖掘批次opp_mining_popup 的按钮端点)
# GET/POST: scope=top|targeted, keyword(targeted必填), category?, days?, sources?
# 复用 opp_mining_flow.start_mining与工具通道同一实现返回 {status,batch_id}。
# 权限logined项目归属从会话当前项目解析与列表页同一口径
uid = await get_user()
if not uid:
return {"status": "error", "message": "未登录"}
dbname = get_module_dbname('pipeline-opportunity')
scope = ((params_kw or {}).get("scope") or "top").strip()
keyword = ((params_kw or {}).get("keyword") or "").strip()
category = ((params_kw or {}).get("category") or "").strip()
try:
days = int((params_kw or {}).get("days") or 365)
except (TypeError, ValueError):
days = 365
sources = ((params_kw or {}).get("sources") or "").strip()
session_id = (params_kw or {}).get('session_id', '') or ''
pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general'
_cur_pid = ''
_uorg = ''
try:
from pipeline_service.workspace import get_session_project_id
async with DBPools().sqlorContext(dbname) as sor:
_cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or ''
_urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid})
await sor.sqlExe("COMMIT", {})
if _urecs:
_uorg = str(getattr(_urecs[0], "orgid", "") or "")
except Exception:
pass
_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg,
"pipeline_id": pipeline_id, "session_id": session_id}
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_mining_flow import start_mining
bid, err = await start_mining(sor, _ctx, scope=scope, days=days, sources=sources,
keyword=keyword, category=category)
await sor.sqlExe("COMMIT", {})
if err:
return {"status": "error", "message": err}
return {"status": "ok", "batch_id": bid}