pipeline-bidding/pipeline_bidding/bid_tender_capability.py
yumoqing 44f8c18ac3 feat(bidding): 产线起点改为上传招标文件+解析产出QC契合度审核门禁
清理(前三步归商机产线):
- 删邮箱采集: bid_mail.py/bid_mail_accounts表与页面/首页按钮
- 删招标审批状态机: TD_*/HT_TENDER_APPROVAL/摘要审批工具族
- bid_tenders瘦身为解析抽取的商务要素(去邮件字段)

QC契合度审核:
- 新表bid_qc_reviews(qc_type/round/fit_score/pass_score/passed/improvement)
- bid_qc_capability: start_qc/finish_qc/list_qc/qc_report, 四类产出逐类审核
- 判定: 10分制打分, >9.5放行(参数bid_qc_pass_score); 不通过给改进意见
  +清空产出+对账器重派解析任务带意见重做; 超轮次抛qc_escalation人工
- bid_flow对账器A2门禁阶段: 幂等/在办去重/轮次守卫/重做不计首跑上限
- 驾驶舱: qc_report工具+解析QC审核菜单; 参数读appbase表+默认兜底
2026-08-31 13:37:49 +08:00

151 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""投标项目能力:立项 / 参与人员 / 等待上传招标文件。
产线起点是「上传招标文件」:招标信息的采集/摘要/审批归商机产线,本产线不再包含。
招标商务要素(项目名称/招标人/截止时间/预算等)由招标文件解析阶段从文件中抽取,
落 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="",
who=None, agent_id=None):
"""直接创建投标项目(产线起点)+ 发「上传招标文件」任务。
立项不经过邮件采集/审批(那是商机产线的职责):用户/agent 直接创建投标项目,
随后上传招标文件进入解析。
"""
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")
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 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)