feat: 任务树QC按审核记录分组(同任务多轮复审=组节点)+rev:轮次叶子详情(MdWidget渲染审核说明/改进意见)

This commit is contained in:
yumoqing 2026-09-03 16:58:30 +08:00
parent 7039c4980e
commit 475ea79f6c
2 changed files with 166 additions and 29 deletions

View File

@ -27,11 +27,74 @@ if not raw_id:
}, ensure_ascii=False)
async with DBPools().sqlorContext(dbname) as sor:
# ── 步骤/交付件节点:先按自身查,查到就展示;查不到回落任务视图 ──
# ── 步骤/交付件/QC审核轮次节点:先按自身查,查到就展示;查不到回落任务视图 ──
task_id = raw_id
step_focus = ''
del_focus = ''
if raw_id.startswith('step:'):
if raw_id.startswith('rev:'):
rid = raw_id[4:]
recs = await sor.sqlExe(
"SELECT id, task_id, qc_type, round, reviewer, fit_score, pass_score, passed, "
"improvement, comments, created_at FROM bid_qc_reviews WHERE id=${r}$", {"r": rid})
if recs:
rv = recs[0]
passed = str(getattr(rv, 'passed', '0')) == '1'
sc = float(getattr(rv, 'fit_score', 0) or 0)
ps = float(getattr(rv, 'pass_score', 0) or 0)
task_id = getattr(rv, 'task_id', '') or ''
subwidgets = [
{"widgettype": "Text",
"options": {"text": "第%s轮 %s QC 契合度审核" % (
getattr(rv, 'round', ''), getattr(rv, 'qc_type', '') or ''),
"halign": "left", "cfontsize": 1.1,
"style": {"fontWeight": "bold", "color": "#1e293b"}}},
{"widgettype": "HBox", "options": {"gap": "12px", "width": "100%"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": ("%.1f / %.1f %s" % (sc, ps, '→ 通过' if passed else '→ 未通过')),
"cfontsize": 0.85, "color": "#ffffff", "padding": "1px 10px",
"style": {"background": '#16a34a' if passed else '#dc2626',
"borderRadius": "12px", "fontWeight": "bold"}}},
{"widgettype": "Text", "options": {
"text": "审核: " + (getattr(rv, 'reviewer', '') or ''),
"cfontsize": 0.75, "color": "#94a3b8"}},
{"widgettype": "Text", "options": {
"text": str(getattr(rv, 'created_at', '') or '')[:16],
"cfontsize": 0.75, "color": "#94a3b8"}},
{"widgettype": "Filler"},
]},
{"widgettype": "Text",
"options": {"text": "\u2500\u2500 审核说明 \u2500\u2500", "cfontsize": 0.85,
"style": {"fontWeight": "bold", "color": "#8b5cf6", "marginTop": "8px"}}},
{"widgettype": "VScrollPanel",
"options": {"css": "filler", "width": "100%", "minHeight": "100px",
"maxHeight": "360px", "padding": "6px 12px", "bgcolor": "#ffffff",
"border": "1px solid #e2e8f0", "borderRadius": "6px"},
"subwidgets": [{"widgettype": "MdWidget",
"options": {"mdtext": getattr(rv, 'comments', '') or '(无)',
"width": "100%"}}]},
]
imp = getattr(rv, 'improvement', '') or ''
if imp:
subwidgets.append({"widgettype": "Text",
"options": {"text": "\u2500\u2500 改进意见 \u2500\u2500", "cfontsize": 0.85,
"style": {"fontWeight": "bold", "color": "#b45309", "marginTop": "8px"}}})
subwidgets.append({"widgettype": "VScrollPanel",
"options": {"css": "filler", "width": "100%", "minHeight": "100px",
"maxHeight": "360px", "padding": "6px 12px", "bgcolor": "#ffffff",
"border": "1px solid #e2e8f0", "borderRadius": "6px"},
"subwidgets": [{"widgettype": "MdWidget",
"options": {"mdtext": imp, "width": "100%"}}]})
subwidgets.append({"widgettype": "Text",
"options": {"text": "(以下为该轮审核所属任务详情)", "cfontsize": 0.75,
"color": "#94a3b8", "marginTop": "8px"}})
return json.dumps({
"widgettype": "VBox",
"options": {"width": "100%", "padding": "12px", "gap": "6px", "overflowY": "auto"},
"subwidgets": subwidgets,
}, ensure_ascii=False)
task_id = raw_id
elif raw_id.startswith('step:'):
sid = raw_id[5:]
recs = await sor.sqlExe(
"SELECT id, task_id, step_name, display_name, step_type, state, error_msg, "

View File

@ -150,28 +150,19 @@ async with DBPools().sqlorContext(dbname) as sor:
role_tasks.append(t)
# ── 维度/类型分组 ──
if role in ('agent.tender_analyst', 'agent.qc'):
if role == 'agent.tender_analyst':
groups = {}
for t in role_tasks:
if role == 'agent.tender_analyst':
key = (_params_of(t).get('analysis_dim') or '').strip() or '__legacy__'
else:
key = qc_map.get(getattr(t, 'id', ''), ('', 0))[0] or '__none__'
key = (_params_of(t).get('analysis_dim') or '').strip() or '__legacy__'
groups.setdefault(key, []).append(t)
for key in groups:
groups[key].sort(key=lambda x: str(getattr(x, 'created_at', '') or ''))
dim_labels = {
'scoring': '评分项与得分规则', 'quals': '资质清单与要求',
'reqs_outline': '投标文件要求与章节骨架', 'cost_benefit': '成本收益分析',
'__legacy__': '全量解析(维度拆分前)',
'scoring_items': '评分项 QC', 'qualifications': '资质 QC',
'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC',
'__none__': 'QC 审核',
}
order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', '__legacy__',
'scoring_items', 'qualifications', 'doc_requirements',
'chapter_outline', '__none__']
order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', '__legacy__']
nodes = []
for key in order + [k for k in groups if k not in order]:
if key not in groups:
@ -179,7 +170,6 @@ async with DBPools().sqlorContext(dbname) as sor:
ts = groups[key]
label = dim_labels.get(key, key)
if len(ts) == 1:
# 一次过:叶子节点
t = ts[0]
st = getattr(t, 'state', '') or ''
nodes.append({
@ -190,7 +180,6 @@ async with DBPools().sqlorContext(dbname) as sor:
del_cnt.get(getattr(t, 'id', ''), 0) > 0),
})
else:
# 多轮:组节点 + 轮次升序子节点
final_state = getattr(ts[-1], 'state', '') or ''
nodes.append({
"id": "grp:" + role + ":" + key,
@ -200,6 +189,66 @@ async with DBPools().sqlorContext(dbname) as sor:
})
return json.dumps(nodes, ensure_ascii=False)
if role == 'agent.qc':
# QC 按审核记录分组(同一任务可多轮复审):一次过=叶子,多轮=组节点
revs = await sor.sqlExe(
"SELECT id, task_id, qc_type, round, fit_score, passed FROM bid_qc_reviews "
"WHERE project_id=${p}$ ORDER BY qc_type, round ASC", {"p": pid}) or []
groups = {}
for rv in revs:
groups.setdefault(getattr(rv, 'qc_type', '') or '__none__', []).append(rv)
reviewed_tids = set(getattr(rv, 'task_id', '') for rv in revs)
legacy = [t for t in role_tasks if getattr(t, 'id', '') not in reviewed_tids]
if legacy:
groups.setdefault('__legacy__', [])
qc_labels = {
'scoring_items': '评分项 QC', 'qualifications': '资质 QC',
'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC',
'__none__': 'QC 审核', '__legacy__': 'QC 审核(早期)',
}
order = ['scoring_items', 'qualifications', 'doc_requirements',
'chapter_outline', '__none__', '__legacy__']
nodes = []
for key in order + [k for k in groups if k not in order]:
if key not in groups:
continue
rs = groups[key]
label = qc_labels.get(key, key)
if legacy and key == '__legacy__':
if not rs and legacy:
# 只有遗留任务无审核记录:多任务=组,单任务=叶
if len(legacy) == 1:
t = legacy[0]
st = getattr(t, 'state', '') or ''
nodes.append({"id": getattr(t, 'id', ''),
"label": "{} [{}] {}".format(_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st), label),
"is_leaf": True})
else:
nodes.append({"id": "grp:" + role + ":" + key,
"label": "{}{} 个早期任务)".format(label, len(legacy)),
"is_leaf": False})
continue
if len(rs) == 1 and str(getattr(rs[0], 'passed', '0')) == '1':
# 一次过:叶子
t_id = getattr(rs[0], 'task_id', '')
sc = getattr(rs[0], 'fit_score', '')
nodes.append({
"id": t_id,
"label": "\u2705 [通过] {}{:.1f} 分)".format(label, float(sc or 0)),
"is_leaf": not (del_cnt.get(t_id, 0) > 0),
})
else:
n_rounds = len(rs) if rs else len(legacy)
last_passed = str(getattr(rs[-1], 'passed', '0')) == '1' if rs else False
nodes.append({
"id": "grp:" + role + ":" + key,
"label": "{}{} 轮)最新[{}]".format(label, n_rounds,
'通过' if last_passed else '未通过'),
"is_leaf": False,
})
return json.dumps(nodes, ensure_ascii=False)
# ── 非维度型角色:平铺任务列表(原逻辑)──
nodes = []
for t in role_tasks:
@ -227,23 +276,50 @@ async with DBPools().sqlorContext(dbname) as sor:
except Exception:
return {}
qc_map = {}
if role == 'agent.qc':
for r in await sor.sqlExe(
"SELECT task_id, qc_type, round FROM bid_qc_reviews WHERE project_id=${p}$",
{"p": pid}) or []:
qc_map[getattr(r, 'task_id', '')] = (getattr(r, 'qc_type', '') or '',
int(getattr(r, 'round', 0) or 0))
# QC 组:按审核记录轮次升序展开(同一任务多轮复审各占一行)
revs = await sor.sqlExe(
"SELECT id, task_id, qc_type, round, fit_score, passed, created_at "
"FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${k}$ "
"ORDER BY round ASC", {"p": pid, "k": key}) or []
if revs:
nodes = []
for rv in revs:
rnd = int(getattr(rv, 'round', 0) or 0)
passed = str(getattr(rv, 'passed', '0')) == '1'
sc = float(getattr(rv, 'fit_score', 0) or 0)
upd = str(getattr(rv, 'created_at', '') or '')[5:16]
icon = '\u2705' if passed else '\U0001f6ab'
nodes.append({
"id": "rev:" + getattr(rv, 'id', ''),
"label": "第%d轮 %s [%s] %.1f 分 %s" % (
rnd, icon, '通过' if passed else '未通过', sc, upd),
"is_leaf": True,
})
return json.dumps(nodes, ensure_ascii=False)
# 无审核记录的早期 QC 任务:平铺
nodes = []
for t in all_tasks:
if (getattr(t, 'role', '') or '').strip() != role:
continue
if (getattr(t, 'parent_id', '') or '').strip():
continue
st = getattr(t, 'state', '') or ''
nodes.append({"id": getattr(t, 'id', ''),
"label": "{} [{}] {}".format(_state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st),
str(getattr(t, 'updated_at', '') or '')[5:16]),
"is_leaf": not (del_cnt.get(getattr(t, 'id', ''), 0) > 0)})
return json.dumps(nodes, ensure_ascii=False)
# analyst 组:按任务创建序(=轮次升序)展开
ts = []
for t in all_tasks:
if (getattr(t, 'role', '') or '').strip() != role:
continue
if (getattr(t, 'parent_id', '') or '').strip():
continue
if role == 'agent.tender_analyst':
k = (_params_of2(t).get('analysis_dim') or '').strip() or '__legacy__'
else:
k = qc_map.get(getattr(t, 'id', ''), ('', 0))[0] or '__none__'
k = (_params_of2(t).get('analysis_dim') or '').strip() or '__legacy__'
if k == key:
ts.append(t)
ts.sort(key=lambda x: str(getattr(x, 'created_at', '') or ''))
@ -251,12 +327,10 @@ async with DBPools().sqlorContext(dbname) as sor:
for i, t in enumerate(ts):
tid = getattr(t, 'id', '')
st = getattr(t, 'state', '') or ''
rnd = qc_map.get(tid, ('', 0))[1] if role == 'agent.qc' else 0
rnd_label = "第%d轮" % (rnd or (i + 1))
upd = str(getattr(t, 'updated_at', '') or '')[5:16]
nodes.append({
"id": tid,
"label": "{} {} [{}] {}".format(rnd_label, _state_icons.get(st, '\u2b1c'),
"label": "第%d轮 %s [%s] %s" % (i + 1, _state_icons.get(st, '\u2b1c'),
_state_zh.get(st, st), upd),
"is_leaf": not (step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0),
})