"""投标项目能力:立项 / 参与人员 / 等待上传招标文件。 产线起点是「上传招标文件」:招标信息的采集/摘要/审批归商机产线,本产线不再包含。 招标商务要素(项目名称/招标人/截止时间/预算等)由招标文件解析阶段从文件中抽取, 落 bid_tenders(source=tender_doc),见 bid_analysis_capability._upsert_tender_facts。 状态机规范见技能 `bid-workflow`;本模块只固化操作原语(CAS 迁移 + 审计),合法性由 LLM 读技能判断。 """ import json import logging from .bid_common import ( get_db, new_id, rec_to_dict, rows_to_dicts, json_loads, to_float, to_int, record, create_human_task, find_human_task, get_project, PIPELINE_ID, HT_TENDER_FILE, ) logger = logging.getLogger("pipeline.bidding.tender") # ══════════════ 立项 / 人员 / 招标文件 ══════════════ async def create_bid_project(name, description="", org_id="", created_by="", tender_content="", tender_file_name="", who=None, agent_id=None): """直接创建投标项目(产线起点)+ 发「上传招标文件」任务。 立项不经过邮件采集/审批(那是商机产线的职责):用户/agent 直接创建投标项目, 随后上传招标文件进入解析。 用户已提供招标文件(附件/正文)时传 tender_content:直接落库并跳过人工上传步骤, 对账器下一轮即派发解析。 """ if not name or not str(name).strip(): return False, "缺少项目名称(name)" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: pid = new_id() pname = str(name).strip()[:190] await sor.C("sd_projects", { "id": pid, "name": pname, "description": (description or "")[:4000], "project_type": "bidding", "pipeline_id": PIPELINE_ID, "status": "in_progress", "org_id": org_id or "0", "created_by": created_by or (who or "agent.pm"), }) await sor.sqlExe("COMMIT", {}) await record(sor, pid, "sd_projects", pid, "create_bid_project", to_state="in_progress", who=who, agent_id=agent_id, detail="direct create") txt = str(tender_content or "").strip() if txt: # 用户已提供招标文件:直接落库,上传任务创建即完成(跳过人工上传步骤) fname = str(tender_file_name or "招标文件(对话提供)")[:250] await sor.C("bid_tender_files", { "id": new_id(), "project_id": pid, "file_name": fname, "file_type": "tender_doc", "content_text": txt[:2000000], "file_size": len(txt.encode("utf-8", "ignore")), "extract_status": "uploaded", "uploaded_by": created_by or (who or "agent.main_agent"), }) hid = await create_human_task( sor, pid, HT_TENDER_FILE, "上传招标文件:%s" % pname, "立项时已随附招标文件,跳过人工上传步骤。", assignee_role="owner.superuser", form_schema={"project_id": pid}) await sor.sqlExe( "UPDATE pipeline_human_tasks SET status='done', qc_status='passed', " "qc_comment='招标文件随立项提供,系统自动完成(跳过上传步骤)', " "submitted_at=NOW() WHERE id=${h}$", {"h": hid}) await sor.sqlExe("COMMIT", {}) await record(sor, pid, "bid_tender_files", hid, "import_tender_file", to_state="done", who=who, agent_id=agent_id, detail="tender content provided at creation, upload skipped") return True, ("project_id=%s(招标文件已导入 %d 字,上传步骤已跳过," "系统将自动开始解析)" % (pid, len(txt))) hid = await create_human_task( sor, pid, HT_TENDER_FILE, "上传招标文件:%s" % pname, ("项目已创建,请上传招标文件(含答疑/补遗文件)。\n" "上传后系统自动进入招标文件解析(抽取评分项、得分规则、所需资质、投标文件要求、章节骨架)。"), assignee_role="owner.superuser", form_schema={"project_id": pid}) return True, "project_id=%s(已发上传招标文件任务 %s)" % (pid, hid) async def import_tender_file(project_id, content_text="", file_path="", file_name="", who=None, agent_id=None): """导入招标文件(已创建的项目补录):落库 bid_tender_files 并跳过人工上传步骤。 content_text 与 file_path 至少给一个。导入后上传任务自动完成,对账器即派解析。 """ if not project_id: return False, "缺少 project_id" txt = str(content_text or "").strip() fp = str(file_path or "").strip() if not txt and not fp: return False, "缺少招标文件内容:请提供 content_text(正文)或 file_path(文件路径)" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: await sor.C("bid_tender_files", { "id": new_id(), "project_id": project_id, "file_name": str(file_name or ("招标文件(对话提供)" if txt else fp.split("/")[-1]))[:250], "file_type": "tender_doc", "content_text": txt[:2000000] if txt else "", "file_path": fp[:500], "file_size": len(txt.encode("utf-8", "ignore")) if txt else None, "extract_status": "uploaded", "uploaded_by": who or "agent.main_agent", }) await sor.sqlExe("COMMIT", {}) ht = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending") if ht: await sor.sqlExe( "UPDATE pipeline_human_tasks SET status='done', qc_status='passed', " "qc_comment='招标文件已导入,系统自动完成(跳过上传步骤)', " "submitted_at=NOW() WHERE id=${h}$", {"h": ht.get("id")}) await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_tender_files", project_id, "import_tender_file", to_state="uploaded", who=who, agent_id=agent_id, detail=("text %d chars" % len(txt)) if txt else ("path %s" % fp)) return True, ("招标文件已导入(%s),上传步骤已跳过,系统将自动开始解析。" % ("正文 %d 字" % len(txt) if txt else file_path)) async def assign_members(project_id, members="", who=None, agent_id=None): """设定参与人员。members 支持 JSON 数组或 "姓名:角色,姓名:角色" 简写。""" if not project_id: return False, "缺少 project_id" items = [] ms = members if isinstance(ms, str) and ms.strip(): parsed = json_loads(ms, None) if isinstance(parsed, list): items = parsed else: for seg in ms.split(","): seg = seg.strip() if not seg: continue if ":" in seg or ":" in seg: nm, rl = seg.replace(":", ":").split(":", 1) items.append({"user_name": nm.strip(), "member_role": rl.strip()}) else: items.append({"user_name": seg, "member_role": "writer"}) elif isinstance(ms, list): items = ms if not items: return False, "未解析出参与人员(示例:张三:负责人,李四:writer)" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: n = 0 for it in items: if not isinstance(it, dict): continue nm = str(it.get("user_name") or it.get("name") or "").strip() if not nm: continue exist = await sor.sqlExe( "SELECT id FROM bid_members WHERE project_id=${pid}$ AND user_name=${nm}$ LIMIT 1", {"pid": project_id, "nm": nm}) await sor.sqlExe("COMMIT", {}) if exist: continue await sor.C("bid_members", { "id": new_id(), "project_id": project_id, "user_id": str(it.get("user_id") or ""), "user_name": nm[:120], "member_role": str(it.get("member_role") or "writer")[:60], "duty": str(it.get("duty") or "")[:480], "status": "active", "created_by": who or "", }) n += 1 await sor.sqlExe("COMMIT", {}) await record(sor, project_id, "bid_members", project_id, "assign_members", who=who, agent_id=agent_id, detail="新增 %d 人" % n) return True, "已设定参与人员 %d 人" % n async def list_members(project_id): db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT user_name, member_role, duty, status FROM bid_members " "WHERE project_id=${pid}$ ORDER BY created_at", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) return rows_to_dicts(recs) async def request_tender_file(project_id, note="", who=None, agent_id=None): """(重新)发布「等待上传招标文件」人类任务。""" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: p = await get_project(sor, project_id) if not p: return False, "项目不存在: %s" % project_id exist = await find_human_task(sor, project_id, HT_TENDER_FILE, status="pending") if exist: return True, "已有待办上传任务: %s" % exist.get("id") hid = await create_human_task( sor, project_id, HT_TENDER_FILE, "上传招标文件:%s" % p.get("name", ""), note or "请上传招标文件(含答疑/补遗),上传后自动进入解析阶段。") await sor.sqlExe("COMMIT", {}) return True, "已发布上传招标文件任务: %s" % hid async def list_tender_files(project_id): db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT id, file_name, file_type, extract_status, file_size, created_at " "FROM bid_tender_files WHERE project_id=${pid}$ ORDER BY created_at", {"pid": project_id}) await sor.sqlExe("COMMIT", {}) return rows_to_dicts(recs)