- bid_flow A3段: 链激活五条件(有招标文件+两阶段启用+骨架QC已过+technical章全pending+无kind='summary'摘要行),任一不满足整链跳过=存量行为(甘肃等已开写项目零影响);完成判定用摘要行(换代闭环:新招标文件reset清bid_tech_items+作废pending确认待办) - C段门禁_tech_gate_on: 链激活只挡technical章,商务/报价章照常流转(HT_TEMPLATE_CONFIRM移出BLOCKING_HT_TYPES降级章节级) - confirm_tech_template双模式分流: 有非technical章=标准流程只删建technical章+全序列重排order_no(走查修复:原min+idx顺延在新骨架章数多于原技术章时与后置商务/报价章撞号,合成ORDER BY会把报价章插进技术章中间);无=纯技术流程整表清落;重确认守卫收窄到待替换范围 - record_tech_summary: 裁剪摘要落bid_tech_items kind='summary'(六类覆盖+模版来源+章数),幂等覆盖,失败不阻塞确认 - _reset_analysis_for_new_file: bid_tech_items入清空表清单+作废pending骨架确认待办 - chapter_detail: technical章注入tech_summary+list_tech_items提示(老库无表静默降级) - BID_FLOW_STAGES 14阶段: +tech_analysis(yes)/tech_qc(warn)/template_confirm(warn,dep=tech_analysis,裁头段必连带裁确认段) - bid_task_tree: analyst加tech维度组/QC加tech_items组/DIM_QCS补tech - README+DESIGN v1.6同步(3.7节A3设计+门禁表tech_template_confirm行)
528 lines
26 KiB
Plaintext
528 lines
26 KiB
Plaintext
# bid_task_tree.dspy - 投标产线任务树(懒加载,与开发产线 /task 同构)
|
||
# 树状结构:项目 → 阶段(角色分组) → 任务 → 步骤/交付件
|
||
# 协议:无 id → 根节点;__root__ → 阶段;role:xxx → 任务;裸任务ID → 步骤+交付件
|
||
|
||
import json
|
||
|
||
uid = await get_user()
|
||
if not uid:
|
||
uid = 'user-01'
|
||
|
||
node_id = (params_kw or {}).get('id', '').strip()
|
||
|
||
dbname = get_module_dbname('pipeline-bidding')
|
||
|
||
_state_icons = {
|
||
'completed': '\u2705', 'approved': '\u2705', 'running': '\U0001f7e1',
|
||
'failed': '\u274c', 'waiting': '\u26a0\ufe0f', 'submitted': '\u2b1c',
|
||
'review': '\U0001f440', 'rejected': '\U0001f6ab', 'paused': '\u23f8\ufe0f',
|
||
'cancelled': '\U0001f6d1', 'qc_review': '\U0001f50d',
|
||
'qc_rejected': '\U0001f6ab', 'verify_failed': '\u26a0\ufe0f',
|
||
}
|
||
_state_zh = {
|
||
'completed': '已完成', 'approved': '已批准', 'running': '运行中',
|
||
'failed': '失败', 'waiting': '等待中', 'submitted': '已提交',
|
||
'review': '审核中', 'rejected': '已驳回', 'paused': '已暂停',
|
||
'cancelled': '已取消', 'qc_review': '审核中',
|
||
'qc_rejected': 'QC不通过', 'verify_failed': '落库核验不通过',
|
||
}
|
||
|
||
# 投标产线角色分组(按业务流转顺序)
|
||
_ROLE_GROUPS = [
|
||
('agent.tender_analyst', '\U0001f50d 招标解析'),
|
||
('agent.qc', '\U0001f4cb QC契合度审核'),
|
||
('agent.bid_prep', '\U0001f4c4 资料准备'),
|
||
('agent.bid_writer', '\u270d\ufe0f 技术章编写'),
|
||
('agent.bid_biz_writer', '\u270d\ufe0f 商务章编写'),
|
||
('agent.bid_reviewer', '\U0001f50e 章节评审'),
|
||
('agent.bid_compositor', '\U0001f4e6 标书合成'),
|
||
('agent.bid_scorer', '\U0001f3af 整书评分'),
|
||
('agent.pm', '\U0001f91d 项目管理'),
|
||
]
|
||
_ROLE_SET = set(r for r, _ in _ROLE_GROUPS)
|
||
|
||
async with DBPools().sqlorContext(dbname) as sor:
|
||
# ── 解析当前投标项目(2026-09-11 收敛:统一走 get_session_project_id,
|
||
# 含悬空自愈+产线隔离;删掉「最近一个进行中的投标项目」第三级兜底——
|
||
# 把别人的项目当当前项目违反「没项目要提示选择或新建」)──
|
||
_sid = (params_kw or {}).get('session_id', '') or ''
|
||
pid = ''
|
||
try:
|
||
from pipeline_service.workspace import get_session_project_id
|
||
pid = await get_session_project_id(sor, uid, _sid, 'bidding_general') or ''
|
||
except Exception:
|
||
pid = ''
|
||
|
||
if not pid:
|
||
if not node_id:
|
||
return json.dumps([{"id": "__root__", "label": "请先选择或新建投标项目", "is_leaf": True}], ensure_ascii=False)
|
||
return json.dumps([], ensure_ascii=False)
|
||
|
||
pname = ''
|
||
precs = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${p}$", {"p": pid})
|
||
if precs:
|
||
pname = getattr(precs[0], 'name', '') or ''
|
||
|
||
if not node_id:
|
||
# 根节点
|
||
return json.dumps([{"id": "__root__", "label": pname or "投标项目", "is_leaf": False}], ensure_ascii=False)
|
||
|
||
# 一次查全部任务 + 步骤数 + 交付件数(项目内任务量不大,Python 分组)
|
||
all_tasks = await sor.sqlExe(
|
||
"SELECT id, title, role, state, params, parent_id, created_at, updated_at "
|
||
"FROM pipeline_tasks WHERE tenant_id=${p}$ ORDER BY created_at ASC", {"p": pid}) or []
|
||
step_cnt = {}
|
||
for r in await sor.sqlExe(
|
||
"SELECT task_id, COUNT(*) AS c FROM pipeline_task_steps "
|
||
"WHERE task_id IN (SELECT id FROM pipeline_tasks WHERE tenant_id=${p}$) GROUP BY task_id",
|
||
{"p": pid}) or []:
|
||
step_cnt[getattr(r, 'task_id', '')] = int(getattr(r, 'c', 0) or 0)
|
||
del_cnt = {}
|
||
for r in await sor.sqlExe(
|
||
"SELECT task_id, COUNT(*) AS c FROM pipeline_deliverables WHERE project_id=${p}$ GROUP BY task_id",
|
||
{"p": pid}) or []:
|
||
del_cnt[getattr(r, 'task_id', '')] = int(getattr(r, 'c', 0) or 0)
|
||
|
||
if node_id == '__root__':
|
||
# 阶段列表(按角色分组,只列有任务的)
|
||
role_cnt = {}
|
||
for t in all_tasks:
|
||
if (getattr(t, 'parent_id', '') or '').strip():
|
||
continue
|
||
r = (getattr(t, 'role', '') or '').strip()
|
||
role_cnt[r] = role_cnt.get(r, 0) + 1
|
||
nodes = []
|
||
for rn, label in _ROLE_GROUPS:
|
||
if rn in role_cnt:
|
||
nodes.append({"id": "role:" + rn,
|
||
"label": "{} ({})".format(label, role_cnt[rn]), "is_leaf": False})
|
||
other_cnt = sum(v for k, v in role_cnt.items() if k not in _ROLE_SET)
|
||
if other_cnt > 0:
|
||
nodes.append({"id": "role:__other__",
|
||
"label": "\U0001f4ad 其他 ({})".format(other_cnt), "is_leaf": False})
|
||
if not nodes:
|
||
nodes = [{"id": "__empty__", "label": "暂无任务", "is_leaf": True}]
|
||
return json.dumps(nodes, ensure_ascii=False)
|
||
|
||
if node_id.startswith('role:'):
|
||
# 该角色分组下的任务。
|
||
# 维度型角色(analyst 按 analysis_dim、qc 按 qc_type):先按维度/类型聚合,
|
||
# 一次过=叶子节点;多轮=组节点,子节点按轮次升序(第1轮→第N轮)。
|
||
role = node_id[5:]
|
||
|
||
def _params_of(t):
|
||
try:
|
||
return json.loads(getattr(t, 'params', '') or '{}')
|
||
except Exception:
|
||
return {}
|
||
|
||
# QC 任务 → (qc_type, round) 映射(review 表的 round 是权威轮次)
|
||
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))
|
||
|
||
role_tasks = []
|
||
for t in all_tasks:
|
||
r = (getattr(t, 'role', '') or '').strip()
|
||
if role == '__other__':
|
||
if r in _ROLE_SET:
|
||
continue
|
||
elif r != role:
|
||
continue
|
||
if (getattr(t, 'parent_id', '') or '').strip():
|
||
continue
|
||
tid = getattr(t, 'id', '')
|
||
if not tid or not (getattr(t, 'title', '') or ''):
|
||
continue
|
||
role_tasks.append(t)
|
||
|
||
# ── 维度/类型分组 ──
|
||
if role == 'agent.tender_analyst':
|
||
# 各维度 QC 审核:最大轮次 + 最新一轮结果(组节点标签用,
|
||
# 轮次以审核记录为准——人工修正任务产出的第4轮也计入)
|
||
DIM_QCS = {'scoring': ('scoring_items',), 'quals': ('qualifications',),
|
||
'reqs_outline': ('doc_requirements', 'chapter_outline'),
|
||
'cost_benefit': ('cost_benefit',)}
|
||
qc_by_type = {}
|
||
for rv in await sor.sqlExe(
|
||
"SELECT qc_type, fit_score, passed, round FROM bid_qc_reviews "
|
||
"WHERE project_id=${p}$ ORDER BY qc_type, round DESC", {"p": pid}) or []:
|
||
qt = getattr(rv, 'qc_type', '') or ''
|
||
if qt not in qc_by_type:
|
||
qc_by_type[qt] = rv
|
||
groups = {}
|
||
for t in role_tasks:
|
||
key = (_params_of(t).get('analysis_dim') or '').strip()
|
||
if not key:
|
||
p = _params_of(t)
|
||
title = getattr(t, 'title', '') or ''
|
||
if p.get('qc_redo'):
|
||
key = '__legacy__'
|
||
elif '修正' in title or '补录' in title:
|
||
# 人工修正任务归入它修正的维度组(标题关键词定维度),
|
||
# 不再单独成组——第4轮产出在维度组里可见
|
||
if 'scoring' in title or '评分项' in title:
|
||
key = 'scoring'
|
||
elif 'qual' in title or '资质' in title:
|
||
key = 'quals'
|
||
elif 'cost' in title or '成本收益' in title:
|
||
key = 'cost_benefit'
|
||
else:
|
||
key = 'reqs_outline'
|
||
else:
|
||
key = '__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': '成本收益分析',
|
||
'tech': '技术评估项(技术方案裁剪依据)',
|
||
'__legacy__': '全量解析(维度拆分前)',
|
||
}
|
||
order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', 'tech', '__legacy__']
|
||
nodes = []
|
||
for key in order + [k for k in groups if k not in order]:
|
||
if key not in groups:
|
||
continue
|
||
ts = groups[key]
|
||
label = dim_labels.get(key, key)
|
||
if len(ts) == 1:
|
||
t = ts[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": not (step_cnt.get(getattr(t, 'id', ''), 0) > 0 or
|
||
del_cnt.get(getattr(t, 'id', ''), 0) > 0),
|
||
})
|
||
else:
|
||
# 轮次 = 该维度 QC 审核记录最大轮次(含人工修正触发的复审)
|
||
max_round = 0
|
||
lq = None
|
||
for qt in DIM_QCS.get(key, ()):
|
||
rv = qc_by_type.get(qt)
|
||
if rv:
|
||
rr = int(getattr(rv, 'round', 0) or 0)
|
||
if rr > max_round:
|
||
max_round = rr
|
||
lq = rv
|
||
if not max_round:
|
||
max_round = len(ts)
|
||
if lq:
|
||
lq_passed = str(getattr(lq, 'passed', '0')) == '1'
|
||
tail = "QC %.1f 分 %s" % (float(getattr(lq, 'fit_score', 0) or 0),
|
||
'通过' if lq_passed else '未通过')
|
||
else:
|
||
tail = "任务" + _state_zh.get(getattr(ts[-1], 'state', '') or '', '')
|
||
nodes.append({
|
||
"id": "grp:" + role + ":" + key,
|
||
"label": "{}({} 轮)最新[{}]".format(label, max_round, tail),
|
||
"is_leaf": False,
|
||
})
|
||
return json.dumps(nodes, ensure_ascii=False)
|
||
|
||
if role == 'agent.qc':
|
||
# QC 按审核记录分组(同一任务可多轮复审):一次过=叶子,多轮=组节点。
|
||
# 2026-09-03:正在执行/还没审核的任务也是任务——无审核记录的 QC 任务
|
||
# 按 params.qc_types 归入对应类型组,组标签带在办数,展开可见。
|
||
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)
|
||
open_tasks = {}
|
||
for t in role_tasks:
|
||
if getattr(t, 'id', '') in reviewed_tids:
|
||
continue
|
||
st = (getattr(t, 'state', '') or '').strip()
|
||
if st in ('cancelled', 'failed'):
|
||
continue # 失败/取消不算在办
|
||
try:
|
||
qts = json.loads(getattr(t, 'params', '') or '{}').get('qc_types') or []
|
||
except Exception:
|
||
qts = []
|
||
if st in ('approved', 'completed'):
|
||
# 早期完成但无审核记录(记录被清理)→ 归遗留组,不算在办
|
||
open_tasks.setdefault('__legacy__', []).append(t)
|
||
continue
|
||
for qt in qts:
|
||
open_tasks.setdefault(qt, []).append(t)
|
||
if not qts:
|
||
open_tasks.setdefault('__legacy__', []).append(t)
|
||
for qt in open_tasks:
|
||
groups.setdefault(qt, [])
|
||
legacy = open_tasks.get('__legacy__', [])
|
||
qc_labels = {
|
||
'scoring_items': '评分项 QC', 'qualifications': '资质 QC',
|
||
'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC',
|
||
'cost_benefit': '成本收益分析 QC', 'tech_items': '技术评估项 QC',
|
||
'__none__': 'QC 审核', '__legacy__': 'QC 审核(早期)',
|
||
}
|
||
order = ['scoring_items', 'qualifications', 'doc_requirements',
|
||
'chapter_outline', 'cost_benefit', 'tech_items',
|
||
'__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)
|
||
n_open = len(open_tasks.get(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' and not n_open:
|
||
# 一次过且无在办:叶子
|
||
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
|
||
tail = '通过' if last_passed else '未通过'
|
||
if n_open:
|
||
tail += ' + %d在办' % n_open
|
||
nodes.append({
|
||
"id": "grp:" + role + ":" + key,
|
||
"label": "{}({} 轮)最新[{}]".format(label, n_rounds, tail),
|
||
"is_leaf": False,
|
||
})
|
||
return json.dumps(nodes, ensure_ascii=False)
|
||
|
||
# ── 非维度型角色:平铺任务列表(原逻辑)──
|
||
nodes = []
|
||
for t in role_tasks:
|
||
tid = getattr(t, 'id', '')
|
||
title = getattr(t, 'title', '') or ''
|
||
state = getattr(t, 'state', '') or ''
|
||
icon = _state_icons.get(state, '\u2b1c')
|
||
zh = _state_zh.get(state, state)
|
||
has_child = step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0 or \
|
||
any((getattr(c, 'parent_id', '') or '').strip() == tid for c in all_tasks)
|
||
nodes.append({
|
||
"id": tid,
|
||
"label": "{} [{}] {}".format(icon, zh, title[:60]),
|
||
"is_leaf": not has_child,
|
||
})
|
||
return json.dumps(nodes, ensure_ascii=False)
|
||
|
||
if node_id.startswith('grp:'):
|
||
# 轮次子节点:按轮数升序(第1轮→第N轮)
|
||
_, role, key = node_id.split(':', 2)
|
||
|
||
def _params_of2(t):
|
||
try:
|
||
return json.loads(getattr(t, 'params', '') or '{}')
|
||
except Exception:
|
||
return {}
|
||
|
||
# 维度→QC类型映射(用于给 analyst 轮次挂该轮 QC 结果)
|
||
DIM_QC = {'scoring': 'scoring_items', 'quals': 'qualifications',
|
||
'reqs_outline': 'doc_requirements', 'cost_benefit': 'cost_benefit'}
|
||
|
||
if role == 'agent.qc':
|
||
# 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 []
|
||
nodes = []
|
||
reviewed_tids = set()
|
||
for rv in revs:
|
||
reviewed_tids.add(getattr(rv, 'task_id', ''))
|
||
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,
|
||
})
|
||
for t in all_tasks:
|
||
if (getattr(t, 'role', '') or '').strip() != role:
|
||
continue
|
||
if (getattr(t, 'parent_id', '') or '').strip():
|
||
continue
|
||
tid = getattr(t, 'id', '')
|
||
if tid in reviewed_tids:
|
||
continue
|
||
st0 = (getattr(t, 'state', '') or '').strip()
|
||
try:
|
||
qts = json.loads(getattr(t, 'params', '') or '{}').get('qc_types') or []
|
||
except Exception:
|
||
qts = []
|
||
if key == '__legacy__':
|
||
# 遗留组:无审核记录的早期终态任务(在办任务归各自类型组)
|
||
if st0 in ('submitted', 'running', 'review', 'qc_review', 'waiting'):
|
||
continue
|
||
else:
|
||
if key not in qts:
|
||
continue
|
||
if st0 in ('cancelled', 'failed', 'approved', 'completed'):
|
||
continue # 终态且有审核记录的已在上面;无记录的归遗留组
|
||
st = st0
|
||
nodes.append({
|
||
"id": tid,
|
||
"label": "%s [%s] %s" % (_state_icons.get(st, '\u2b1c'),
|
||
_state_zh.get(st, st),
|
||
(getattr(t, 'title', '') or '')[:40]),
|
||
"is_leaf": not (del_cnt.get(tid, 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
|
||
k = (_params_of2(t).get('analysis_dim') or '').strip()
|
||
if not k:
|
||
p = _params_of2(t)
|
||
title = getattr(t, 'title', '') or ''
|
||
if p.get('qc_redo'):
|
||
k = '__legacy__'
|
||
elif '修正' in title or '补录' in title:
|
||
if 'scoring' in title or '评分项' in title:
|
||
k = 'scoring'
|
||
elif 'qual' in title or '资质' in title:
|
||
k = 'quals'
|
||
elif 'cost' in title or '成本收益' in title:
|
||
k = 'cost_benefit'
|
||
else:
|
||
k = 'reqs_outline'
|
||
else:
|
||
k = '__legacy__'
|
||
if k == key:
|
||
ts.append(t)
|
||
ts.sort(key=lambda x: str(getattr(x, 'created_at', '') or ''))
|
||
|
||
# reqs_outline 维度含两类审核记录,合并取每轮最大
|
||
DIM_QCS = {'scoring': ('scoring_items',), 'quals': ('qualifications',),
|
||
'reqs_outline': ('doc_requirements', 'chapter_outline'),
|
||
'cost_benefit': ('cost_benefit',), 'tech': ('tech_items',)}
|
||
rev_by_round = {}
|
||
for qt in DIM_QCS.get(key, ()):
|
||
for rv in await sor.sqlExe(
|
||
"SELECT 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": qt}) or []:
|
||
rr = int(getattr(rv, 'round', 0) or 0)
|
||
if rr not in rev_by_round:
|
||
rev_by_round[rr] = rv
|
||
|
||
nodes = []
|
||
used_rounds = set()
|
||
max_round = max(rev_by_round) if rev_by_round else 0
|
||
for i, t in enumerate(ts):
|
||
tid = getattr(t, 'id', '')
|
||
st = getattr(t, 'state', '') or ''
|
||
t_upd = str(getattr(t, 'updated_at', '') or '')
|
||
upd = t_upd[5:16]
|
||
nxt_created = str(getattr(ts[i + 1], 'created_at', '') or '9999') if i + 1 < len(ts) else '9999'
|
||
is_manual = not (_params_of2(t).get('analysis_dim') or '').strip() and \
|
||
not _params_of2(t).get('qc_redo') and \
|
||
('修正' in (getattr(t, 'title', '') or '') or '补录' in (getattr(t, 'title', '') or ''))
|
||
matched = None
|
||
if not is_manual:
|
||
for rnd, rv in rev_by_round.items():
|
||
if rnd in used_rounds:
|
||
continue
|
||
rv_t = str(getattr(rv, 'created_at', '') or '')
|
||
if t_upd and rv_t >= t_upd and rv_t < nxt_created:
|
||
matched = (rnd, rv)
|
||
break
|
||
if matched:
|
||
rnd, rv = matched
|
||
used_rounds.add(rnd)
|
||
passed = str(getattr(rv, 'passed', '0')) == '1'
|
||
sc = float(getattr(rv, 'fit_score', 0) or 0)
|
||
qc_label = "→ QC %.1f 分 %s" % (sc, '通过' if passed else '未通过')
|
||
lbl = "第%d轮 %s [任务%s] %s %s" % (
|
||
rnd, _state_icons.get(st, '\u2b1c'), _state_zh.get(st, st), qc_label, upd)
|
||
elif is_manual:
|
||
# 修正任务不挂轮次号(轮次以 QC 审核记录为准,见 QC 组)
|
||
lbl = "修正 %s [任务%s] %s" % (_state_icons.get(st, '\u2b1c'),
|
||
_state_zh.get(st, st),
|
||
(getattr(t, 'title', '') or '')[:40])
|
||
else:
|
||
lbl = "第%d轮 %s [任务%s] → 未审核/审核记录未关联 %s" % (
|
||
i + 1, _state_icons.get(st, '\u2b1c'), _state_zh.get(st, st), upd)
|
||
nodes.append({
|
||
"id": tid,
|
||
"label": lbl,
|
||
"is_leaf": not (step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0),
|
||
})
|
||
return json.dumps(nodes, ensure_ascii=False)
|
||
|
||
# ── 裸任务 ID:子任务 + 执行步骤 + 交付件 ──
|
||
nodes = []
|
||
for t in all_tasks:
|
||
if (getattr(t, 'parent_id', '') or '').strip() != node_id:
|
||
continue
|
||
tid = getattr(t, 'id', '')
|
||
title = getattr(t, 'title', '') or ''
|
||
state = getattr(t, 'state', '') or ''
|
||
if not tid or not title:
|
||
continue
|
||
icon = _state_icons.get(state, '\u2b1c')
|
||
zh = _state_zh.get(state, state)
|
||
nodes.append({"id": tid, "label": "{} [{}] {}".format(icon, zh, title[:60]),
|
||
"is_leaf": not (step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0)})
|
||
|
||
steps = await sor.sqlExe(
|
||
"SELECT id, step_name, display_name, state FROM pipeline_task_steps "
|
||
"WHERE task_id=${t}$ ORDER BY step_order ASC", {"t": node_id}) or []
|
||
for s in steps:
|
||
sid = getattr(s, 'id', '')
|
||
label = getattr(s, 'display_name', '') or getattr(s, 'step_name', '') or ''
|
||
st = getattr(s, 'state', '') or ''
|
||
if label:
|
||
nodes.append({"id": "step:" + sid,
|
||
"label": " {} [{}] \u2699\ufe0f {}".format(_state_icons.get(st, '\u2b1c'),
|
||
_state_zh.get(st, st), label[:50]),
|
||
"is_leaf": True})
|
||
|
||
dels = await sor.sqlExe(
|
||
"SELECT id, deliverable_type, title FROM pipeline_deliverables "
|
||
"WHERE task_id=${t}$ ORDER BY created_at DESC", {"t": node_id}) or []
|
||
for d in dels:
|
||
did = getattr(d, 'id', '')
|
||
dt = getattr(d, 'deliverable_type', '') or ''
|
||
dtitle = getattr(d, 'title', '') or ''
|
||
nodes.append({"id": "del:" + did,
|
||
"label": " \U0001f4e4 {}: {}".format(dt, (dtitle or '')[:40]),
|
||
"is_leaf": True})
|
||
|
||
return json.dumps(nodes, ensure_ascii=False)
|