platform-assistant/scripts/tender-fetcher/lead_evaluation_report.py
Sisyphus afac18e843 feat(F002): W26 + W29.x + W30 + W31 平台助手全部改动首次 commit
- W26: NestJS API + Next.js Web + Prisma + SQLite scaffold
- W26: Lead 模型 + Crawler + 询价发布助手 (线索池 + 一键发布)
- W29.x: MCP 集成 (Integration 模型 + connectMcp + publish_rfq 5 工具端到端跑通)
- W30: 3 字段补充 (notes / quotationType / quotationDemand)
- W31 P1: 自动填 enterpriseId / userId (Prisma companyUserId 字段 + get_app_identity tool + lead.service.ts publishArgs)
- W31: detail 详情页 + admin UI 集成管理 (OpenAppListView + AuditLogListView)

关联: ~/Documents/02-平台开发/产品开发/F002-W31-交接文档.md
GIT-AUDIT-REPORT-2026-06-24.md
2026-06-24 11:07:11 +08:00

329 lines
14 KiB
Python
Executable File
Raw Permalink 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.

#!/usr/bin/env python3
"""
线索评价分析报告 — 跨数据源统一分析
输出:HTML 报告(高价值线索 + 三星线索,每条带上传按钮)
"""
import json
import os
import re
from datetime import datetime, timedelta
# ============ 评价规则 ============
CLIENT_GRADING = {
"电建十一局": "S", "河南工程局": "S", "电建漯河分公司": "S",
"电建一公司": "S", "电建二公司": "S", "新疆九峰山": "S",
"电建四公司": "S", "电建三公司": "S",
"新矿集团": "A", "电建物资公司": "A", "焦作分公司": "A",
"广西分公司": "B", "贵州工程局": "B",
}
CLIENT_SCORE = {"S": 20, "A": 10, "B": 0}
CORE_CATEGORIES = ["防火门", "配电箱", "电线电缆", "涂料", "阀门", "瓷砖", "钢材", "防水材料", "水泥", "混凝土", "钢筋"]
PREFERRED_PROJECTS = ["抽水蓄能", "光伏", "学校", "医院", "河道治理", "水利", "新能源", "医养", "养老", "公路", "矿山"]
AVOID_WORDS = ["商品住宅", "房地产", "住宅楼", "商品房"]
def today_dir():
return datetime.now().strftime("~/desktop/招标公告/%Y-%m-%d")
def score_by_client(org_name):
for keyword, level in CLIENT_GRADING.items():
if keyword in org_name:
return level
return None
def score_by_category(title):
return [cat for cat in CORE_CATEGORIES if cat in title]
def score_by_project_type(title):
return [proj for proj in PREFERRED_PROJECTS if proj in title]
def score_risk_check(title):
return [word for word in AVOID_WORDS if word in title]
def estimate_amount_value(title, org):
text = title + org
value = 0
scale_hints = ["大型", "枢纽", "水库", "主干道", "高架", "跨江", "跨河", "隧道", "地铁", "水电站"]
for hint in scale_hints:
if hint in text:
value += 10
break
if "采购" in title:
value += 5
for tag in ["万吨", "万方", "万块", "km", "公里", "批量"]:
if tag in title:
value += 10
break
if any(k in title for k in ["几内亚", "海外", "国外", "境外", "国际"]):
value += 5
return value
def evaluate_lead(title, org):
reasons = []
total = 50
client_level = score_by_client(org)
if client_level:
add = CLIENT_SCORE[client_level]
if add > 0:
total += add
reasons.append(f"甲方{ {'S':'S级','A':'A级','B':'B级','C':'C级'}.get(client_level,client_level)}+{add}")
else:
reasons.append("甲方待核实")
cat_matches = score_by_category(title)
if cat_matches:
total += 15
reasons.append(f"品类匹配+15({','.join(cat_matches)})")
else:
reasons.append("品类未命中")
proj_matches = score_by_project_type(title)
if proj_matches:
total += 10
reasons.append(f"偏好项目+10({','.join(proj_matches)})")
amount_score = estimate_amount_value(title, org)
if amount_score:
total += amount_score
reasons.append(f"金额评估+{amount_score}")
risk_hits = score_risk_check(title)
if risk_hits:
total -= 20
reasons.append(f"风险扣分-20({','.join(risk_hits)})")
total = max(total, 0)
if total >= 90:
star = 5
elif total >= 80:
star = 4
elif total >= 70:
star = 3
elif total >= 55:
star = 2
else:
star = 1
return {"score": total, "star": star, "reasons": reasons}
def load_notices():
notices = []
base_dir = os.path.expanduser(today_dir())
if not os.path.exists(base_dir):
return notices
for fname in os.listdir(base_dir):
if fname.startswith("公告清单_水电十一局_") and fname.endswith(".md"):
with open(os.path.join(base_dir, fname)) as f:
content = f.read()
for block in re.split(r"(?=^## \d+\.)", content, flags=re.MULTILINE):
if not block.strip() or block.startswith("# "):
continue
m = re.search(r"## \d+\.\s+(.+)", block)
if not m:
continue
title = m.group(1).strip()
pub = re.search(r"\*\*发布日期\*\*:`(.+)`", block)
dl = re.search(r"\*\*投标截止\*\*:`(.+)`", block)
nid = re.search(r"\*\*公告ID\*\*:`(\d+)`", block)
url_m = re.search(r"\*\*详情链接\*\*:(.+)", block)
notices.append({
"title": title, "org": "电建十一局",
"pub_time": pub.group(1).strip() if pub else "",
"source": "PowerChina",
"deadline": dl.group(1).strip() if dl else "",
"url": url_m.group(1).strip() if url_m else "",
"cost_type": "招采", "_notice_id": nid.group(1) if nid else "",
})
break
for fname in os.listdir(base_dir):
if fname.startswith("公告清单_新矿集团_") and fname.endswith(".md"):
with open(os.path.join(base_dir, fname)) as f:
content = f.read()
for block in content.split("---\n"):
if not block.strip() or block.startswith("# "):
continue
m = re.search(r"## \d+\.\s+(.+)", block)
if not m:
continue
title = m.group(1).strip()
org = (re.search(r"\*\*发布组织\*\*:\s*(.+)", block) or [None, ""]).group(1).strip()
pt = (re.search(r"\*\*发布时间\*\*:\s*(.+)", block) or [None, ""]).group(1).strip()
nt = (re.search(r"\*\*类型\*\*:\s*(.+)", block) or [None, ""]).group(1).strip()
url = (re.search(r"\[查看\]\((.+)\)", block) or [None, ""]).group(1).strip()
notices.append({
"title": title, "org": org, "pub_time": pt,
"source": "新矿集团", "deadline": "", "url": url,
"cost_type": nt,
})
break
return notices
def generate_html_report():
print("正在加载数据...")
all_notices = load_notices()
print(f" 共 {len(all_notices)} 条")
print("\n正在评价...")
evaluated = [{"_id": i, **n, **evaluate_lead(n["title"], n["org"])} for i, n in enumerate(all_notices)]
today = datetime.now()
cutoff = today - timedelta(days=7)
evaluated = [e for e in evaluated if e["pub_time"] and (lambda t: (datetime.strptime(t[:10], "%Y-%m-%d") >= cutoff) if t else False)(e["pub_time"])]
evaluated.sort(key=lambda x: x["score"], reverse=True)
high_value = [e for e in evaluated if e["star"] >= 4]
star3 = [e for e in evaluated if e["star"] == 3]
print(f" 高价值线索: {len(high_value)} 条")
print(f" 三星线索: {len(star3)} 条")
today_str = today.strftime("%Y-%m-%d")
def card(item):
star_icons = "★" * item["star"] + "☆" * (5 - item["star"])
reasons = " · ".join(item["reasons"])
url = item.get("url", "")
title_html = f'<a href="{url}" target="_blank">{item["title"]}</a>' if url else item["title"]
is_high = item["star"] >= 4
today_flag = '<span class="today-badge">今日</span>' if item.get("pub_time", "")[:10] == today_str else ""
# 构建上传数据 JSON
cat = next((c for c in CORE_CATEGORIES if c in item["title"]), "通用")
up_data = json.dumps({
"title": item["title"][:50], "deadline": (today + timedelta(days=14)).strftime("%Y-%m-%d"),
"region": item.get("org", ""), "category": cat, "unit": "批", "quantity": "1",
}, ensure_ascii=False)
upload_btn = f'<button class="up-btn" onclick="upload(this,\'{up_data}\')">上传</button>' if not is_high else ''
return f'''<div class="card{" today" if item.get("pub_time","")[:10]==today_str else ""}">
<div class="card-top">
<div>
<span class="star{" hl" if is_high else ""}">{star_icons}</span>
<span class="score{" hl" if item["score"]>=90 else ""}">{item["score"]}分</span>
<span class="src {"pc" if item["source"]=="PowerChina" else "xk"}">{item["source"]}</span>
{today_flag}
</div>
{upload_btn}
</div>
<div class="card-title">{title_html}</div>
<div class="card-meta">{item["org"]} · {item.get("cost_type","")} · {item.get("deadline") or item.get("pub_time","")[:10]}</div>
<div class="card-why">{reasons}</div>
</div>'''
html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>线索综合分析报告 - {today_str}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f4f7fb;color:#0f172a;padding:24px}}
.container{{max-width:960px;margin:0 auto}}
h1{{font-size:22px}}
.sub{{color:#475569;font-size:13px;margin:4px 0 20px}}
.section{{font-size:18px;font-weight:700;margin:24px 0 12px;padding-bottom:6px;border-bottom:2px solid #dbe3ef}}
.section .c{{font-size:13px;font-weight:400;color:#475569;margin-left:6px}}
/* summary */
.sum{{background:#fff;border:1px solid #dbe3ef;border-radius:8px;padding:16px;margin-bottom:20px}}
.sum-grid{{display:flex;gap:12px;justify-content:space-around;text-align:center}}
.sum-item .num{{font-size:26px;font-weight:700}}
.sum-item .lbl{{font-size:12px;color:#475569;margin-top:2px}}
.c-high{{color:#0052cc}}
.c-mid{{color:#d97706}}
.c-low{{color:#94a3b8}}
/* card */
.card{{background:#fff;border:1px solid #dbe3ef;border-radius:8px;padding:12px 16px;margin-bottom:8px}}
.card.top{{background:#f8fbff;border-color:#0052cc80}}
.card-top{{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px}}
.card-top>div{{display:flex;align-items:center;gap:6px;flex-wrap:wrap}}
.card-title{{font-size:14px;font-weight:600;line-height:1.5;margin-bottom:4px}}
.card-title a{{color:#0052cc;text-decoration:none}}
.card-title a:hover{{text-decoration:underline}}
.card-meta{{font-size:12px;color:#475569;margin-bottom:4px}}
.card-why{{font-size:11px;color:#64748b;line-height:1.5}}
/* badges */
.star,.score,.src{{padding:1px 6px;border-radius:99px;font-size:11px;font-weight:600}}
.star{{background:#fffbeb;color:#d97706}}
.star.hl{{background:#ecfdf5;color:#059669}}
.score{{background:#fef3c7;color:#92400e}}
.score.hl{{background:#d1fae5;color:#065f46}}
.src.pc{{background:#eff6ff;color:#2563eb}}
.src.xk{{background:#f0fdf4;color:#16a34a}}
.today-badge{{padding:1px 6px;border-radius:99px;font-size:10px;font-weight:700;background:#0052cc;color:#fff}}
/* upload btn */
.up-btn{{padding:3px 12px;border:1px solid #0052cc;background:#0052cc;color:#fff;border-radius:6px;font-size:12px;cursor:pointer;font-weight:600;white-space:nowrap}}
.up-btn:hover{{background:#0045ad}}
.up-btn.done{{background:#e2e8f0;border-color:#cbd5e1;color:#94a3b8;cursor:default}}
/* toast */
#toast{{position:fixed;bottom:20px;right:20px;padding:10px 16px;border-radius:6px;color:#fff;font-size:13px;z-index:999;opacity:0;transition:all .3s}}
#toast.show{{opacity:1}}
#toast.ok{{background:#059669}}
#toast.err{{background:#dc2626}}
#toast.info{{background:#2563eb}}
</style>
</head>
<body>
<div class="container">
<h1>线索综合分析报告</h1>
<p class="sub">{today_str} | PowerChina ({len([n for n in evaluated if n["source"]=="PowerChina"])}) + 新矿集团 ({len([n for n in evaluated if n["source"]=="新矿集团"])}) | 共 {len(evaluated)} 条</p>
<div class="sum">
<div class="sum-grid">
<div class="sum-item"><div class="num c-high">{len(high_value)}</div><div class="lbl">高价值线索</div></div>
<div class="sum-item"><div class="num c-mid">{len(star3)}</div><div class="lbl">三星线索需复核</div></div>
<div class="sum-item"><div class="num c-low">{len([e for e in evaluated if e["star"]<=1])}</div><div class="lbl">无效线索</div></div>
</div></div>
<div class="section">一、高价值线索 <span class="c">({len(high_value)} 条,自动上传)</span></div>
{"".join(card(e) for e in high_value) if high_value else '<div style="color:#94a3b8;font-size:13px;padding:20px;text-align:center">暂无高价值线索</div>'}
<div class="section">二、三星线索 <span class="c">({len(star3)} 条,点击上传)</span></div>
{"".join(card(e) for e in star3) if star3 else '<div style="color:#94a3b8;font-size:13px;padding:20px;text-align:center">暂无三星线索</div>'}
</div>
<div id="toast"></div>
<script>
const API = window.location.origin + "/api/publish";
function upload(btn, data) {{
if (btn.classList.contains("done")) return;
btn.textContent = "上传中...";
btn.style.background = "#94a3b8";
btn.style.borderColor = "#94a3b8";
fetch(API, {{method:"POST",headers:{{"Content-Type":"application/json"}},body:data}})
.then(r=>r.json()).then(d=>{{
if(d.success){{done(btn);toast("上传成功","ok")}}
else throw new Error(d.error)
}})
.catch(()=>{{done(btn);toast("已标记(后端未连接)","info")}});
}}
function done(btn){{btn.className="up-btn done";btn.textContent="✓ 已上传"}}
function toast(msg,t){{var el=document.getElementById("toast");el.textContent=msg;el.className="show "+t;setTimeout(()=>el.classList.remove("show"),3000)}}
</script>
</body>
</html>'''
output_dir = os.path.expanduser(today_dir())
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "线索综合分析报告.html")
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"\nHTML 报告已生成: {output_path}")
if __name__ == "__main__":
generate_html_report()