diff --git a/pipeline_opportunity/opp_ability.py b/pipeline_opportunity/opp_ability.py index 2dc5b38..7a8a77f 100644 --- a/pipeline_opportunity/opp_ability.py +++ b/pipeline_opportunity/opp_ability.py @@ -600,9 +600,13 @@ async def _h_promote_to_project(sor, p, ctx): rid = (p.get("report_id") or "").strip() if not rid: return _fmt(False, "report_id 必填") - ok, pid = await promote_to_project(sor, rid, ctx) + ok, res = await promote_to_project(sor, rid, ctx) if not ok: - return _fmt(False, pid) + return _fmt(False, res) + if isinstance(res, dict) and res.get("reused"): + return _fmt(True, "本报告已立项(幂等):project_id=%s 项目「%s」,不重复建" + % (res.get("project_id"), res.get("project_name", ""))) + pid = res.get("project_id") if isinstance(res, dict) else res return _fmt(True, "已立项 project_id=%s(pipeline=opportunity_general," "project_type=demand_mining)" % pid) diff --git a/pipeline_opportunity/opp_feasibility.py b/pipeline_opportunity/opp_feasibility.py index 03cb37a..55bfd9d 100644 --- a/pipeline_opportunity/opp_feasibility.py +++ b/pipeline_opportunity/opp_feasibility.py @@ -285,12 +285,21 @@ async def promote_to_project(sor, report_id, ctx): return False, "报告未过人工确认门禁(status=%s),不能立项" % (rep.get("status") or "draft") if (rep.get("appr_status") or "") != "approved": return False, "研发审批未通过(status=%s),不能立项" % (rep.get("appr_status") or "无审批单") + # 幂等守卫(2026-09-14 E2E 实测:重复调用会重复建项目):description 带 + # report_id 精确标记,立项前先查——已立过则返回既有项目,不再新建。 + desc = "由需求挖掘可行性报告立项:%s [report_id=%s]" % (rep.get("title"), report_id) + exist = await sor.sqlExe( + "SELECT id, name FROM sd_projects WHERE description=${d}$ LIMIT 1", {"d": desc}) + await sor.sqlExe("COMMIT", {}) + if exist: + return True, {"project_id": getattr(exist[0], "id", ""), + "project_name": getattr(exist[0], "name", ""), "reused": True} from pipeline_service.project_capability import create_project ok, pid = await create_project( name="%s 研发项目" % (rep.get("software") or "未命名"), - project_type="demand_mining", description="由需求挖掘可行性报告立项:%s" % rep.get("title"), + project_type="demand_mining", description=desc, org_id=ctx.get("org_id") or "0", created_by=ctx.get("user_id") or "", pipeline_id="opportunity_general") if not ok: return False, "立项失败: %s" % pid - return True, pid + return True, {"project_id": pid, "reused": False} diff --git a/scripts/p3_feasibility_e2e.py b/scripts/p3_feasibility_e2e.py index 4eacdd2..81b3ad7 100644 --- a/scripts/p3_feasibility_e2e.py +++ b/scripts/p3_feasibility_e2e.py @@ -280,13 +280,18 @@ async def m(): np is not None and np.project_type == "demand_mining" and np.pipeline_id == "opportunity_general" and np.org_id == "0", "%s/%s/%s" % (getattr(np, "project_type", ""), getattr(np, "pipeline_id", ""), getattr(np, "org_id", ""))) - # 重复立项行为记录(当前实现无幂等守卫,重复调用会再建项目——如实记录,清理兜底) + # 幂等守卫:重复 promote 必须复用既有项目,不再新建 l2 = await call("opp_promote_to_project", {"report_id": RID}) - DUP = l2.startswith("OK:") and "project_id=" in l2 - if DUP: - DUPPID = l2.split("project_id=")[1].split("(")[0].strip() - print("-- FINDING: promote 重复调用再建项目 %s(无幂等守卫)--" % DUPPID, flush=True) - check("L3 重复立项行为已记录", l2.startswith("OK:") or l2.startswith("ERROR:"), l2[:120]) + DUPPID = "" + if l2.startswith("OK:") and "project_id=" in l2: + cand = l2.split("project_id=")[1].split("(")[0].split(" ")[0].strip() + if cand and cand != NEWPID: + DUPPID = cand + n_prj = await sql("SELECT COUNT(*) n FROM sd_projects WHERE description LIKE ${p}$", + {"p": "由需求挖掘可行性报告立项%%[report_id=%s]%%" % RID}, one=True) + check("L3 重复立项幂等(不重复建)", l2.startswith("OK:") and "幂等" in l2 + and not DUPPID and getattr(n_prj, "n", 99) == 1, + "%s | dup=%s | n=%s" % (l2[:120], DUPPID or "-", getattr(n_prj, "n", "?"))) # ── M. 清理(报告+PPT保留供浏览器验收;立项项目删除)── PROMOTED = [x for x in (NEWPID, locals().get("DUPPID", "")) if x]