471 lines
20 KiB
Python
471 lines
20 KiB
Python
"""商机产线报告/审批能力:研发报告编写 → 人工确认 → 研发审批流程。
|
||
|
||
状态机(REPORT_TRANSITIONS,opp_common):
|
||
draft → confirmed → approval_initiated → approved / rejected
|
||
└ rejected → draft(可重来)
|
||
|
||
门禁纪律:
|
||
- confirm 只能 draft → confirmed,且是人工决策(由人工任务回流驱动)
|
||
- initiate_approval 只能 confirmed → approval_initiated(未经确认禁止发起审批)
|
||
- 审批结论由人工决策回流(approve/reject),agent 不得自批自审
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
|
||
from ahserver.serverenv import ServerEnv
|
||
|
||
from .opp_common import (
|
||
get_db, new_id, rec_to_dict, rows_to_dicts, can_transition,
|
||
RP_DRAFT, RP_CONFIRMED, RP_APPROVAL_INITIATED, RP_APPROVED, RP_REJECTED,
|
||
AP_INITIATED, AP_APPROVED, AP_REJECTED,
|
||
HT_REPORT_CONFIRM, HT_DEV_APPROVAL,
|
||
find_human_task, create_human_task, get_report,
|
||
)
|
||
|
||
logger = logging.getLogger("pipeline.opportunity.report")
|
||
|
||
|
||
async def create_report(project_id, software, title="", analysis="",
|
||
created_by="agent.opportunity"):
|
||
"""创建研发报告草稿。返回 (True, report_id) 或 (False, 错误)。"""
|
||
if not software:
|
||
return False, "缺少 software(软件/主题名)"
|
||
if not title:
|
||
title = "%s 研发机会分析报告" % software
|
||
db, dbname = get_db()
|
||
rid = new_id()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
await sor.C("opp_reports", {
|
||
"id": rid,
|
||
"project_id": project_id or "",
|
||
"software": software,
|
||
"title": title,
|
||
"content": analysis or "",
|
||
"status": RP_DRAFT,
|
||
"created_by": created_by,
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, rid
|
||
|
||
|
||
async def update_report(report_id, content):
|
||
"""更新草稿报告内容(仅 draft 可改)。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
rep = await get_report(sor, report_id)
|
||
if not rep:
|
||
return False, "报告不存在: %s" % report_id
|
||
if rep.get("status") != RP_DRAFT:
|
||
return False, "状态 %s 不可修改(仅草稿可编辑)" % rep.get("status")
|
||
await sor.U("opp_reports", {"id": report_id, "content": content})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, "已更新"
|
||
|
||
|
||
async def list_reports(project_id="", status=""):
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
sql = ("SELECT id, project_id, software, title, status, created_by, "
|
||
"created_at, updated_at FROM opp_reports WHERE 1=1")
|
||
args = {}
|
||
if project_id:
|
||
sql += " AND project_id=${pid}$"
|
||
args["pid"] = project_id
|
||
if status:
|
||
sql += " AND status=${st}$"
|
||
args["st"] = status
|
||
sql += " ORDER BY created_at DESC LIMIT 50"
|
||
recs = await sor.sqlExe(sql, args)
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|
||
|
||
|
||
# ══════════════════ 报告可见性 / 四节模板 / 成果文件(2026-09-09 用户需求)══════════════════
|
||
# 「研发报告」按钮改造:按项目 owner 过滤列表;详情按四节(研发场景/输入/输出/成果)
|
||
# 展示;成果文件多个列出、逐个下载。
|
||
|
||
# 报告正文四节模板(二级标题,agent 写报告必须按此结构;旧报告无节时全文归「输出」)
|
||
REPORT_SECTIONS = ("研发场景", "输入", "输出", "成果")
|
||
|
||
|
||
async def _user_org(sor, user_id):
|
||
recs = await sor.sqlExe(
|
||
"SELECT orgid FROM users WHERE id=${u}$", {"u": user_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return str(getattr(recs[0], "orgid", "") or "") if recs else ""
|
||
|
||
|
||
async def list_visible_reports(sor, user_id):
|
||
"""按项目 owner 过滤的研发报告列表(2026-09-09 用户要求)。
|
||
|
||
可见性规则(用户确认口径):
|
||
- 挂项目的报告:当前用户是项目 owner(sd_projects.created_by==user_id)可见;
|
||
agent 创建的项目(created_by LIKE 'agent.%')降级为同机构成员可见
|
||
(对齐 check_project_owner 的既有降级语义,不重复发明)
|
||
- 未挂项目的报告(平台级调研,现有数据大多数是这类):登录用户可见
|
||
- 项目已删除的孤儿报告:owner 无法验证 → 不可见(宁缺勿漏,LEFT JOIN 自然排除)
|
||
"""
|
||
if not user_id:
|
||
return []
|
||
uorg = await _user_org(sor, user_id)
|
||
# ⚠️ LIKE 模式必须走 ${kw}$ 参数化——SQL 字面量里的裸 % 会被驱动当格式化符
|
||
# (实测 ValueError: unsupported format character;rag_client.py 的
|
||
# NOT LIKE 'agent.%' 是同一颗潜伏雷)。参数值里的 % 安全。
|
||
sql = (
|
||
"SELECT r.id, r.project_id, r.software, r.title, r.status, r.ppt_path, "
|
||
"r.created_by, r.created_at, r.updated_at, "
|
||
"p.name AS project_name, p.created_by AS project_owner "
|
||
"FROM opp_reports r LEFT JOIN sd_projects p ON r.project_id = p.id "
|
||
"WHERE (r.project_id IS NULL OR r.project_id = '') "
|
||
" OR p.created_by = ${uid}$ "
|
||
" OR (p.created_by LIKE ${agent_pat}$ "
|
||
" AND p.org_id = ${uorg}$ AND ${uorg}$ <> '') "
|
||
"ORDER BY r.created_at DESC LIMIT 200")
|
||
recs = await sor.sqlExe(sql, {"uid": user_id, "uorg": uorg,
|
||
"agent_pat": "agent.%"})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|
||
|
||
|
||
async def check_report_visible(sor, report_id, user_id):
|
||
"""单报告可见性校验(详情/下载端点复用列表同一规则,语义不漂移)。
|
||
返回 (visible: bool, report: dict|None)。"""
|
||
if not report_id or not user_id:
|
||
return False, None
|
||
uorg = await _user_org(sor, user_id)
|
||
sql = (
|
||
"SELECT r.*, p.name AS project_name, p.created_by AS project_owner, "
|
||
"p.workspace_dir AS project_dir "
|
||
"FROM opp_reports r LEFT JOIN sd_projects p ON r.project_id = p.id "
|
||
"WHERE r.id = ${rid}$")
|
||
recs = await sor.sqlExe(sql, {"rid": report_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, None
|
||
rep = rec_to_dict(recs[0])
|
||
pid = str(rep.get("project_id") or "")
|
||
if not pid:
|
||
return True, rep # 未挂项目:登录可见
|
||
owner = str(rep.get("project_owner") or "")
|
||
if not owner:
|
||
return False, rep # 孤儿报告(项目已删):owner 无法验证
|
||
if owner == user_id:
|
||
return True, rep
|
||
if owner.startswith("agent.") and uorg:
|
||
# agent 建的项目降级同机构(对齐 check_project_owner 既有语义)
|
||
recs2 = await sor.sqlExe(
|
||
"SELECT org_id FROM sd_projects WHERE id=${p}$", {"p": pid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
proj_org = str(getattr(recs2[0], "org_id", "") or "") if recs2 else ""
|
||
if proj_org and proj_org == uorg:
|
||
return True, rep
|
||
return False, rep
|
||
|
||
|
||
def parse_report_sections(content):
|
||
"""按四节模板解析报告正文 → {'研发场景': md, '输入': md, '输出': md, '成果': md}。
|
||
|
||
模板(agent 写报告约定):二级标题 `## 研发场景` / `## 输入` / `## 输出` / `## 成果`。
|
||
兼容标题带序号/冒号/加粗(如 `## 一、研发场景:`、`## **输出**`)。
|
||
旧报告(无任何匹配节,如实测的市场调研报告)→ 全文归「输出」节,其余节为空,
|
||
详情页对空节如实显示「(本报告未填写此节)」,不编造。
|
||
"""
|
||
import re as _re
|
||
content = content or ""
|
||
# 节标题行:## [可选序号/标点] 节名 [可选冒号/加粗残留]
|
||
pattern = _re.compile(
|
||
r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[**]*\s*"
|
||
r"(研发场景|输入|输出|成果)\s*[**]*\s*[::]?\s*$",
|
||
_re.MULTILINE)
|
||
marks = list(pattern.finditer(content))
|
||
sections = {k: "" for k in REPORT_SECTIONS}
|
||
if not marks:
|
||
sections["输出"] = content.strip()
|
||
return sections
|
||
# 首个节标题之前的前言(如报告大标题/数据来源说明)归「输出」节头部展示
|
||
preamble = content[:marks[0].start()].strip()
|
||
for i, m in enumerate(marks):
|
||
name = m.group(1)
|
||
end = marks[i + 1].start() if i + 1 < len(marks) else len(content)
|
||
body = content[m.end():end].strip()
|
||
# 同名节重复出现时拼接(容错,不丢内容)
|
||
sections[name] = (sections[name] + "\n\n" + body).strip() if sections[name] else body
|
||
if preamble:
|
||
sections["输出"] = (preamble + ("\n\n" + sections["输出"] if sections["输出"] else "")).strip()
|
||
return sections
|
||
|
||
|
||
async def list_report_files(sor, rep):
|
||
"""报告的成果文件清单(2026-09-09 用户要求:多个文件列出、点击下载)。
|
||
|
||
范围(用户确认口径):项目工作空间 deliverables/ 目录全部文件 + 报告 PPT
|
||
(ppt_path 可能就在 deliverables/ 里,按 realpath 去重)。
|
||
未挂项目的报告:只有 PPT(若有)。
|
||
返回 [{'name','path','size','mtime'}],path 为服务端绝对路径——
|
||
下载端点以本清单为权威(对齐 opp_attachment_dl 的防穿越模式:
|
||
用户只传文件名,服务端回查清单命中才给文件,绝不拿用户输入拼路径)。
|
||
"""
|
||
import os as _os
|
||
files = {}
|
||
|
||
def _add(p):
|
||
try:
|
||
rp = _os.path.realpath(p)
|
||
if _os.path.isfile(rp) and rp not in files:
|
||
st = _os.stat(rp)
|
||
files[rp] = {"name": _os.path.basename(rp), "path": rp,
|
||
"size": st.st_size, "mtime": st.st_mtime}
|
||
except Exception:
|
||
pass
|
||
|
||
proj_dir = str((rep or {}).get("project_dir") or "")
|
||
if proj_dir:
|
||
deliv = _os.path.join(proj_dir, "deliverables")
|
||
try:
|
||
if _os.path.isdir(deliv):
|
||
for fn in sorted(_os.listdir(deliv)):
|
||
_add(_os.path.join(deliv, fn))
|
||
except Exception:
|
||
pass
|
||
_add(str((rep or {}).get("ppt_path") or ""))
|
||
out = sorted(files.values(), key=lambda f: -f["mtime"])
|
||
return out
|
||
|
||
|
||
async def get_report_full(report_id):
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
rep = await get_report(sor, report_id)
|
||
return rep
|
||
|
||
|
||
async def _build_report_ppt(sor, rep):
|
||
"""为报告生成 PPT,存到项目工作空间 deliverables/ 目录。返回文件路径(失败 '')。"""
|
||
from .opp_ppt import build_report_ppt
|
||
# 解析项目目录({workspace}/{org}/{space}/projects/{项目}/)
|
||
project_id = rep.get("project_id", "") or ""
|
||
project_dir = ""
|
||
if project_id:
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT workspace_dir FROM sd_projects WHERE id=${p}$",
|
||
{"p": project_id})
|
||
if recs:
|
||
project_dir = getattr(recs[0], "workspace_dir", "") or ""
|
||
except Exception:
|
||
project_dir = ""
|
||
if not project_dir:
|
||
# 无项目/无目录:兜底放平台 files 目录
|
||
project_dir = os.path.join(os.getcwd(), "files", "opp_reports")
|
||
rid = rep.get("id", "")
|
||
software = re.sub(r"[^\w\u4e00-\u9fff-]", "_", rep.get("software", "") or "report")[:40]
|
||
fname = "research_report_%s_%s.pptx" % (software, rid[:8])
|
||
out_path = os.path.join(project_dir, "deliverables", fname)
|
||
ok, res = build_report_ppt(
|
||
rep.get("title", ""), rep.get("content", ""),
|
||
software=rep.get("software", ""), out_path=out_path)
|
||
if not ok:
|
||
logger.warning("build_report_ppt 失败: %s", str(res)[:200])
|
||
return ""
|
||
return out_path
|
||
|
||
|
||
async def submit_for_confirmation(report_id, note=""):
|
||
"""agent 完成报告后提交待人工确认:发人工确认任务(门禁)。
|
||
|
||
报告保持 draft,创建/复用 pending 人工任务。人工确认后才进 confirmed。
|
||
同时生成报告 PPT(存项目工作空间 deliverables/),供确认人下载审阅。
|
||
"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
rep = await get_report(sor, report_id)
|
||
if not rep:
|
||
return False, "报告不存在: %s" % report_id
|
||
if rep.get("status") != RP_DRAFT:
|
||
return False, "状态 %s 不可提交确认" % rep.get("status")
|
||
# 生成/更新报告 PPT(幂等:重复提交覆盖旧文件)
|
||
ppt_path = ""
|
||
try:
|
||
ppt_path = await _build_report_ppt(sor, rep)
|
||
except Exception as e:
|
||
logger.warning("报告PPT生成失败(不阻塞提交): %s", str(e)[:200])
|
||
if ppt_path:
|
||
await sor.U("opp_reports", {"id": report_id, "ppt_path": ppt_path})
|
||
# 幂等:本报告已有 pending 确认任务则复用(按 confirm_task_id 绑定,
|
||
# 不复用其他报告的任务——确认是逐份报告的独立门禁,
|
||
# 且待办页按 confirm_task_id 反查报告正文,绑定丢失则报告无法被确认)
|
||
exist_id = rep.get("confirm_task_id") or ""
|
||
if exist_id:
|
||
t_recs = await sor.sqlExe(
|
||
"SELECT id, status FROM pipeline_human_tasks WHERE id=${i}$",
|
||
{"i": exist_id})
|
||
if t_recs and getattr(t_recs[0], "status", "") == "pending":
|
||
return True, "已有待确认任务: %s" % exist_id
|
||
hid = await create_human_task(
|
||
sor, rep.get("project_id", ""), HT_REPORT_CONFIRM,
|
||
"确认研发报告:%s" % rep.get("title", ""),
|
||
note or "请审阅研发报告《%s》,确认后发起研发审批。" % rep.get("title", ""))
|
||
await sor.U("opp_reports", {
|
||
"id": report_id, "confirm_task_id": hid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, "已提交人工确认任务: %s" % hid
|
||
|
||
|
||
async def confirm_report(report_id, ok, operator=""):
|
||
"""人工确认回流(门禁)。ok=True → confirmed;ok=False → 退回草稿待改。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
rep = await get_report(sor, report_id)
|
||
if not rep:
|
||
return False, "报告不存在: %s" % report_id
|
||
if rep.get("status") != RP_DRAFT:
|
||
return False, "状态 %s 无法确认(仅草稿可确认)" % rep.get("status")
|
||
new_status = RP_CONFIRMED if ok else RP_DRAFT
|
||
await sor.U("opp_reports", {
|
||
"id": report_id, "status": new_status,
|
||
"confirmed_by": operator or "human"})
|
||
# 完结对应的人工任务
|
||
hid = rep.get("confirm_task_id")
|
||
if hid:
|
||
await sor.U("pipeline_human_tasks", {
|
||
"id": hid, "status": "done" if ok else "rejected"})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, "已确认" if ok else "已退回草稿"
|
||
|
||
|
||
async def initiate_approval(report_id, note="", created_by="agent.opportunity",
|
||
applicant_id=""):
|
||
"""发起研发审批:要求报告已 confirmed(人工确认门禁)。
|
||
|
||
审批通道优先级:
|
||
1. 钉钉审批——dda_approval_configs 配了 biz_type=opp_dev_approval 且启用时,
|
||
调 dingdingflow.submit_approval 发起,审批人节点链由配置的
|
||
approvers_config 决定;结果经回调写回(见 opp_common 注册的钩子)。
|
||
2. 降级人工——无钉钉配置/发起失败时,落平台内人工审批任务兜底,
|
||
保证产线闭环不因缺少钉钉环境而卡死。
|
||
"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
rep = await get_report(sor, report_id)
|
||
if not rep:
|
||
return False, "报告不存在: %s" % report_id
|
||
if not await can_transition(rep.get("status"), RP_APPROVAL_INITIATED):
|
||
return False, "状态 %s 不可发起审批(须先人工确认)" % rep.get("status")
|
||
aid = new_id()
|
||
await sor.C("opp_approvals", {
|
||
"id": aid,
|
||
"report_id": report_id,
|
||
"project_id": rep.get("project_id", ""),
|
||
"status": AP_INITIATED,
|
||
"note": note,
|
||
"created_by": created_by,
|
||
})
|
||
await sor.U("opp_reports", {
|
||
"id": report_id, "status": RP_APPROVAL_INITIATED})
|
||
await sor.sqlExe("COMMIT", {})
|
||
|
||
# 尝试钉钉审批通道
|
||
dd_ok = False
|
||
env = ServerEnv()
|
||
submit_approval = getattr(env, "submit_approval", None)
|
||
if submit_approval is not None:
|
||
try:
|
||
org_id = rep.get("org_id", "") or "0"
|
||
r = await submit_approval(
|
||
"opp_dev_approval", aid,
|
||
"研发立项审批:%s" % rep.get("title", ""),
|
||
applicant_id or created_by, org_id)
|
||
if isinstance(r, dict) and r.get("success") and r.get("instance_id"):
|
||
dd_ok = True
|
||
logger.info("研发审批已推钉钉: report=%s instance=%s",
|
||
report_id, r.get("instance_id"))
|
||
else:
|
||
logger.warning("钉钉审批发起未成功(降级人工): report=%s msg=%s",
|
||
report_id, (r or {}).get("message", ""))
|
||
except Exception as e:
|
||
logger.warning("钉钉审批发起异常(降级人工): report=%s err=%s",
|
||
report_id, str(e)[:200])
|
||
|
||
if not dd_ok:
|
||
# 降级:平台内人工审批任务
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
await create_human_task(
|
||
sor, rep.get("project_id", ""), HT_DEV_APPROVAL,
|
||
"研发审批:%s" % rep.get("title", ""),
|
||
note or "请审批《%s》的研发立项。" % rep.get("title", ""))
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, aid
|
||
return True, aid
|
||
|
||
|
||
async def resolve_approval(approval_id, result, operator=""):
|
||
"""审批结论回流(人工决策)。result=approved/rejected。"""
|
||
if result not in (AP_APPROVED, AP_REJECTED):
|
||
return False, "result 必须是 approved 或 rejected"
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, report_id, project_id, status FROM opp_approvals WHERE id=${i}$",
|
||
{"i": approval_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "审批单不存在: %s" % approval_id
|
||
rec = rec_to_dict(recs[0])
|
||
if rec.get("status") != AP_INITIATED:
|
||
return False, "审批单状态 %s 无法结论" % rec.get("status")
|
||
report_status = RP_APPROVED if result == AP_APPROVED else RP_REJECTED
|
||
await sor.U("opp_approvals", {
|
||
"id": approval_id, "status": result,
|
||
"resolved_by": operator or "human"})
|
||
await sor.U("opp_reports", {
|
||
"id": rec.get("report_id"), "status": report_status})
|
||
# 完结人工审批任务
|
||
ht = await find_human_task(sor, rec.get("project_id", ""),
|
||
HT_DEV_APPROVAL, status="pending")
|
||
if ht:
|
||
await sor.U("pipeline_human_tasks", {
|
||
"id": ht.get("id"),
|
||
"status": "done" if result == AP_APPROVED else "rejected"})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, result
|
||
|
||
|
||
async def on_dingtalk_approval_done(biz_id, status, approval_id, comment):
|
||
"""钉钉审批结果回调钩子(dingdingflow register_biz_handler 签名)。
|
||
|
||
biz_id = opp_approvals.id(发起审批时传入)。
|
||
status ∈ approved / rejected / cancelled,cancelled 按驳回处理
|
||
(产线不能停在 approval_initiated 永久卡死)。
|
||
"""
|
||
if status == "approved":
|
||
result = AP_APPROVED
|
||
else:
|
||
result = AP_REJECTED
|
||
ok, msg = await resolve_approval(
|
||
biz_id, result, operator="dingtalk:%s" % (approval_id or ""))
|
||
if ok:
|
||
logger.info("钉钉审批结论已回流: opp_approval=%s -> %s", biz_id, result)
|
||
else:
|
||
logger.warning("钉钉审批结论回流失败: opp_approval=%s msg=%s", biz_id, msg)
|
||
|
||
|
||
async def list_approvals(project_id="", status=""):
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
sql = ("SELECT id, report_id, project_id, status, note, created_by, "
|
||
"created_at, resolved_at FROM opp_approvals WHERE 1=1")
|
||
args = {}
|
||
if project_id:
|
||
sql += " AND project_id=${pid}$"
|
||
args["pid"] = project_id
|
||
if status:
|
||
sql += " AND status=${st}$"
|
||
args["st"] = status
|
||
sql += " ORDER BY created_at DESC LIMIT 50"
|
||
recs = await sor.sqlExe(sql, args)
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|