302 lines
15 KiB
Python
302 lines
15 KiB
Python
"""投标项目能力:立项 / 参与人员 / 等待上传招标文件。
|
||
|
||
产线起点是「上传招标文件」:招标信息的采集/摘要/审批归商机产线,本产线不再包含。
|
||
招标商务要素(项目名称/招标人/截止时间/预算等)由招标文件解析阶段从文件中抽取,
|
||
落 bid_tenders(source=tender_doc),见 bid_analysis_capability._upsert_tender_facts。
|
||
|
||
状态机规范见技能 `bid-workflow`;本模块只固化操作原语(CAS 迁移 + 审计),合法性由 LLM 读技能判断。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
|
||
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]
|
||
# 创建时定死项目目录并写库(统一机制:解析只读库不推导,
|
||
# 保证上传落盘=agent工作目录=工作控件显示目录)
|
||
from pipeline_service.workspace import alloc_project_dir
|
||
ws_path, dname = await alloc_project_dir(sor, org_id or "0", PIPELINE_ID, pname, pid)
|
||
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"),
|
||
"workspace_dir": ws_path,
|
||
"directory_name": dname,
|
||
})
|
||
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="",
|
||
project_name="", org_id="", created_by="",
|
||
who=None, agent_id=None):
|
||
"""导入招标文件;未建项目时自动立项(产线新起点:收到招标书直接启动,2026-09-01)。
|
||
|
||
- 有 project_id:落库 bid_tender_files 并跳过人工上传步骤;
|
||
- 无 project_id:先自动创建投标项目(名称取 project_name,缺省用文件名/「投标项目」),
|
||
再导入招标文件——用户不必先走立项步骤,收到招标书即启动项目。
|
||
|
||
content_text 与 file_path 至少给一个。导入后上传任务自动完成,对账器即派解析。
|
||
"""
|
||
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:
|
||
pid = str(project_id or "").strip()
|
||
auto_created = False
|
||
if not pid:
|
||
# 自动立项:收到招标书直接启动,无需先建项目/配人
|
||
pname = str(project_name or "").strip()[:190]
|
||
if not pname:
|
||
pname = (str(file_name or "").strip() or (fp.split("/")[-1] if fp else "")
|
||
or txt.split("\n")[0].strip())[:190] or "投标项目"
|
||
pid = new_id()
|
||
# 创建时定死项目目录并写库(统一机制,同 create_bid_project)
|
||
from pipeline_service.workspace import alloc_project_dir
|
||
ws_path, dname = await alloc_project_dir(
|
||
sor, org_id or "0", PIPELINE_ID, pname, pid)
|
||
await sor.C("sd_projects", {
|
||
"id": pid,
|
||
"name": pname,
|
||
"description": "由导入招标文件自动立项(收到招标书直接启动)",
|
||
"project_type": "bidding",
|
||
"pipeline_id": PIPELINE_ID,
|
||
"status": "in_progress",
|
||
"org_id": org_id or "0",
|
||
"created_by": created_by or (who or "agent.main_agent"),
|
||
"workspace_dir": ws_path,
|
||
"directory_name": dname,
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
await record(sor, pid, "sd_projects", pid, "auto_create_bid_project",
|
||
to_state="in_progress", who=who, agent_id=agent_id,
|
||
detail="auto-created on tender import")
|
||
auto_created = True
|
||
else:
|
||
p = await get_project(sor, pid)
|
||
if not p:
|
||
return False, "项目不存在: %s" % pid
|
||
# 2026-09-15 修复(bug A 根治半边):相对路径在落库前归一为磁盘绝对路径。
|
||
# 上传链路可能给出相对工作空间根的路径(如 "0/xxx.docx"),而读取侧
|
||
# _resolve_file_path 按项目目录解析——口径不一致导致文件在盘上却永远读不到正文
|
||
# (首医项目实测)。落库时磁盘绝对路径是已知的,存相对路径本身就是隐患。
|
||
if fp and not os.path.isabs(fp):
|
||
real = ""
|
||
try:
|
||
from .bid_analysis_capability import _resolve_file_path, _project_dir_of, _workspace_base_of
|
||
pdir = await _project_dir_of(sor, pid)
|
||
real = _resolve_file_path(pdir, fp, ws_base=await _workspace_base_of(sor))
|
||
except Exception as e:
|
||
logger.warning("tender path normalize failed fp=%s: %s", fp, str(e)[:120])
|
||
if real:
|
||
fp = real
|
||
else:
|
||
logger.warning("tender path not resolvable on disk, stored as-is: %s", fp)
|
||
await sor.C("bid_tender_files", {
|
||
"id": new_id(), "project_id": pid,
|
||
"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, pid, 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, pid, "bid_tender_files", pid, "import_tender_file",
|
||
to_state="uploaded", who=who, agent_id=agent_id,
|
||
detail=("text %d chars" % len(txt)) if txt else ("path %s" % fp))
|
||
base = ("正文 %d 字" % len(txt)) if txt else file_path
|
||
if auto_created:
|
||
return True, ("project_id=%s(已自动立项并导入招标文件:%s,"
|
||
"系统将自动开始解析)" % (pid, base))
|
||
return True, ("招标文件已导入(%s),上传步骤已跳过,系统将自动开始解析。" % base)
|
||
|
||
|
||
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):
|
||
"""(重新)发布「等待上传招标文件」人类任务。
|
||
|
||
硬门禁(2026-09-01,同一事项只发一次待办):
|
||
- 项目已有招标文件记录(bid_tender_files)→ 文件已到位,**拒绝发待办**,
|
||
提示用 list_tender_files 查看/解析会自动进行(防「文件已在还催人上传」);
|
||
- 已有 pending 上传待办 → 返回已有任务 id,不重复创建。
|
||
"""
|
||
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
|
||
n = await _count_files(sor, project_id)
|
||
if n > 0:
|
||
return False, ("项目已有 %d 个招标文件记录,无需再发上传待办"
|
||
"(list_tender_files 查看,解析自动进行)" % n)
|
||
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 _count_files(sor, project_id):
|
||
recs = await sor.sqlExe(
|
||
"SELECT COUNT(*) AS c FROM bid_tender_files WHERE project_id=${p}$",
|
||
{"p": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return to_int(getattr(recs[0], "c", 0) if recs else 0)
|
||
|
||
|
||
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)
|