aps-agent/server/aps_domain/project_analyze.py

315 lines
14 KiB
Python
Raw Normal View History

# ============================================================
# 项目深度分析(moduleId: domain-project-analyze, 可重生 ✅)
# 工程目录 Excel/SQL → 写入当前项目世界 → 明细表 + 齐备度 + 知识库补洞 plan
# ============================================================
from __future__ import annotations
import os
import re
from typing import Any
from server.aps_domain.readiness import check_readiness
World = dict[str, Any]
def _project_ctx(session_id: str | None) -> tuple[dict | None, str]:
try:
from server.state.projects import get_project_store
snap = get_project_store().snapshot(include_messages=False)
except Exception:
return None, ""
sessions = {s["id"]: s for s in (snap.get("sessions") or []) if isinstance(s, dict)}
projects = {p["id"]: p for p in (snap.get("projects") or []) if isinstance(p, dict)}
sess = sessions.get(session_id or "") or {}
proj = projects.get(sess.get("projectId") or "") if sess.get("projectId") else None
work = (proj.get("workDir") or "").strip() if proj else ""
return proj, work
def _fill_from_knowledge(world: World, product_codes: list[str]) -> list[dict[str, Any]]:
"""缺工艺时用行业模板/知识库自动补;返回动作清单。"""
actions: list[dict[str, Any]] = []
try:
from server.knowledge.routing_templates import recommend_templates, apply_template_to_product
except Exception:
return actions
routes = {r.get("productCode") for r in (world.get("flexRoutings") or [])}
mats = {m.get("code") for m in (world.get("flexMaterials") or [])}
for pc in product_codes:
if not pc:
continue
if pc not in mats:
world.setdefault("flexMaterials", []).append({
"id": len(world.get("flexMaterials") or []) + 1,
"code": pc, "name": pc, "type": "FINISHED_PRODUCT", "unit": "件",
"stock": 0, "inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0,
})
actions.append({"type": "material_stub", "productCode": pc,
"detail": f"物料主数据缺失,已建产品档案占位 {pc}"})
if pc in routes:
continue
name = next((m.get("name") for m in (world.get("flexMaterials") or [])
if m.get("code") == pc), pc)
try:
recs = recommend_templates(f"{pc} {name}") or []
except Exception:
recs = []
if not recs:
actions.append({"type": "need_routing", "productCode": pc,
"detail": f"产品 {pc} 无工艺,知识库也无匹配模板,需客户提供工艺路线"})
continue
tpl = recs[0]
code = tpl.get("code") or tpl.get("templateCode")
try:
apply_template_to_product(world, code, pc)
actions.append({
"type": "template_applied", "productCode": pc,
"template": code, "templateName": tpl.get("name"),
"detail": f"产品 {pc} 缺工艺 → 已用知识库模板「{tpl.get('name') or code}」生成",
})
routes.add(pc)
except Exception as exc:
actions.append({"type": "template_failed", "productCode": pc,
"detail": f"模板 {code} 应用失败:{exc}"})
return actions
def analyze_project_deep(
world: World,
session_id: str | None = None,
*,
apply_sql: bool = True,
query: str = "",
) -> dict[str, Any]:
"""深度分析当前项目:目录包(SQL/Excel)+ 世界主数据 + 知识库补洞。"""
try:
from server.agent_core.progress import emit_thinking
except Exception:
def emit_thinking(*_a, **_k): # type: ignore
return None
proj, work = _project_ctx(session_id)
sources: list[str] = []
sql_preview: dict[str, Any] | None = None
applied: dict[str, int] = {}
kb_actions: list[dict[str, Any]] = []
emit_thinking("定位工程目录", (proj or {}).get("name") or "当前项目", pct=2)
if work and os.path.isdir(work):
names = sorted(os.listdir(work))
sqls = [n for n in names if n.lower().endswith(".sql") and not n.startswith("~$")]
xls = [n for n in names if re.search(r"\.(xlsx|xlsm|csv)$", n, re.I) and not n.startswith("~$")]
emit_thinking(
"清点目录文件",
f"SQL {len(sqls)} · Excel/CSV {len(xls)}",
pct=5,
)
if sqls:
from server.importers.sql_pack import preview_sql_file, apply_sql_pack_to_world
# 优先最大的 sql(完整库)
sqls_sorted = sorted(sqls, key=lambda n: os.path.getsize(os.path.join(work, n)), reverse=True)
path = os.path.join(work, sqls_sorted[0])
sources.append(f"SQL:{sqls_sorted[0]}")
try:
sql_preview = preview_sql_file(path)
if apply_sql and sql_preview.get("flex"):
emit_thinking("写入项目世界", "订单 / 物料 / 工艺 / 设备", pct=93)
# SQL 包是工程目录真相源,始终整包覆盖,避免旧错误工艺残留
applied = apply_sql_pack_to_world(
world, sql_preview["flex"], replace=True)
emit_thinking(
"入库完成",
"、".join(f"{k}{v}" for k, v in applied.items()) or "无新增",
status="done", pct=95,
)
except Exception as exc:
sql_preview = {"error": str(exc), "filename": sqls_sorted[0]}
emit_thinking("SQL 解析失败", str(exc), status="warn")
if xls:
sources.append(f"Excel×{len(xls)}")
try:
from server.aps_domain.folder_pack import analyze_work_dir
emit_thinking("核对 Excel/CSV", f"{len(xls)} 个表格", pct=96)
# 仅作文件清单;主数据以 SQL/世界为准
folder = analyze_work_dir(world, session_id)
except Exception:
folder = None
else:
folder = None
else:
folder = None
emit_thinking("未挂工程目录", "仅分析当前项目世界里的数据", status="warn", pct=10)
# 知识库补洞:缺工艺的产品
emit_thinking("检查齐备度与知识库", "缺工艺时自动匹配行业模板", pct=97)
active = [o for o in (world.get("flexOrders") or [])
if (o.get("status") or "") not in ("DONE", "CANCELLED")]
pcs = list({str(o.get("productCode") or "") for o in active})
kb_actions = _fill_from_knowledge(world, pcs)
if kb_actions:
emit_thinking(
"知识库补洞",
f"{len(kb_actions)} 项(模板/占位/待客户补)",
status="done", pct=98,
)
ready = check_readiness(world)
emit_thinking("整理分析报告", f"订单 {len(active)} 张", status="done", pct=99)
mats = world.get("flexMaterials") or []
routes = world.get("flexRoutings") or []
equip = [e for e in (world.get("flexEquipment") or []) if e.get("status") == "RUNNING"]
bom = world.get("flexBom") or []
order_rows = [{
"orderNo": o.get("orderNo"),
"productCode": o.get("productCode"),
"productName": o.get("productName") or next(
(m.get("name") for m in mats if m.get("code") == o.get("productCode")), ""),
"quantity": o.get("quantity"),
"dueDate": o.get("dueDate"),
"customerName": o.get("customerName") or "",
"ready": next((r["ready"] for r in ready["orders"] if r["orderNo"] == o.get("orderNo")), False),
} for o in active[:80]]
# 主数据抽样:订单涉及的产品优先
need_codes = {r["productCode"] for r in order_rows if r.get("productCode")}
mat_rows = []
for m in mats:
if need_codes and m.get("code") not in need_codes and len(mat_rows) >= 40:
continue
if m.get("code") in need_codes or len(mat_rows) < 40:
mat_rows.append({
"code": m.get("code"), "name": m.get("name"),
"type": m.get("type"), "unit": m.get("unit"), "stock": m.get("stock"),
})
if len(mat_rows) >= 60:
break
route_rows = []
for r in routes:
if need_codes and r.get("productCode") not in need_codes and len(route_rows) >= 40:
continue
route_rows.append({
"productCode": r.get("productCode"),
"seq": r.get("seq"),
"operationCode": r.get("operationCode"),
"operationName": r.get("operationName") or r.get("operationCode"),
"stdMin": r.get("stdTimePerUnit"),
})
if len(route_rows) >= 80:
break
equip_rows = [{
"code": e.get("code"), "name": e.get("name"),
"capabilities": "、".join((e.get("capabilities") or [])[:6]),
} for e in equip[:40]]
# 缺口 plan:先库后客户(同类缺口合并,避免刷屏)
plan: list[str] = []
need_rt = [a for a in kb_actions if a.get("type") == "need_routing"]
applied_tpl = [a for a in kb_actions if a.get("type") == "template_applied"]
other_kb = [a for a in kb_actions if a.get("type") not in ("need_routing", "template_applied")]
if applied_tpl:
plan.append(f"✓ 已用知识库模板补工艺 {len(applied_tpl)} 个产品")
for a in applied_tpl[:5]:
plan.append(f" · {a.get('productCode')} ← {a.get('templateName') or a.get('template')}")
if len(applied_tpl) > 5:
plan.append(f" · …另有 {len(applied_tpl) - 5} 个")
if need_rt:
samples = "、".join(str(a.get("productCode") or "") for a in need_rt[:8])
more = f" 等共 {len(need_rt)} 个" if len(need_rt) > 8 else f"({len(need_rt)} 个)"
plan.append(
f"△ SQL/知识库仍未挂上工艺的产品:{samples}{more}。"
f"若现场库本有工艺,请重新「分析项目」(工艺按订单 craftl 关联,不是中间件料号)"
)
for a in other_kb[:6]:
plan.append(f"· {a['detail']}")
s = ready["summary"]
if s.get("blocked"):
plan.append(f"△ 仍有 {s['blocked']} 张单齐备度未过,见订单明细「是否可排」列")
if not active:
plan.append("△ 没有订单:请在工程目录放订单表/SQL,或说「新建订单」")
if not equip:
plan.append("△ 没有可用设备:请提供设备台账,或从 SQL 的 md_equipment 导入")
if not plan:
plan.append("✓ 主数据与知识库已齐,可直接说「给我排产」")
proj_name = (proj or {}).get("name") or "当前项目"
md_lines = [
f"**项目深度分析 · {proj_name}**",
"",
f"数据来源:{'、'.join(sources) if sources else '当前项目世界(未挂工程目录文件)'}",
]
if applied:
md_lines.append(
"已从 SQL 入库:"
+ "、".join(f"{k} {v}" for k, v in applied.items())
)
if sql_preview and sql_preview.get("fieldMapText"):
md_lines.append(f"SQL 包识别:{sql_preview['fieldMapText']}")
md_lines += [
"",
f"订单 **{len(active)}** · 物料 **{len(mats)}** · 工艺步 **{len(routes)}** · "
f"设备 **{len(equip)}** · BOM **{len(bom)}**",
"",
"### 订单明细(本项目)",
"",
"| 订单号 | 产品 | 名称 | 数量 | 交期 | 客户 | 可排 |",
"| --- | --- | --- | ---: | --- | --- | --- |",
]
for r in order_rows[:30]:
md_lines.append(
f"| {r['orderNo']} | {r['productCode']} | {str(r.get('productName') or '')[:16]} "
f"| {r['quantity']} | {r['dueDate'] or '—'} | {str(r.get('customerName') or '')[:12]} "
f"| {'✓' if r['ready'] else '×'} |"
)
if len(order_rows) > 30:
md_lines.append(f"| … | 另有 {len(order_rows) - 30} 张 | | | | | |")
md_lines += [
"",
"### 主数据(产品/物料抽样)",
"",
"| 编码 | 名称 | 类型 | 单位 | 库存 |",
"| --- | --- | --- | --- | ---: |",
]
for m in mat_rows[:25]:
md_lines.append(
f"| {m['code']} | {str(m.get('name') or '')[:20]} | {m.get('type')} "
f"| {m.get('unit')} | {m.get('stock') or 0} |"
)
md_lines += ["", "### 补数 Plan(先知识库/数据库,再找客户)", ""]
for i, p in enumerate(plan[:12], 1):
md_lines.append(f"{i}. {p}")
return {
"ok": True,
"projectName": proj_name,
"workDir": work,
"sources": sources,
"applied": applied,
"sqlPreview": {
"filename": (sql_preview or {}).get("filename"),
"fieldMapText": (sql_preview or {}).get("fieldMapText"),
"tables": (sql_preview or {}).get("tables") or [],
"error": (sql_preview or {}).get("error"),
} if sql_preview else None,
"folder": {"files": (folder or {}).get("files") or []} if folder else None,
"orders": order_rows,
"materials": mat_rows,
"routings": route_rows,
"equipment": equip_rows,
"kbActions": kb_actions,
"plan": plan,
"readiness": ready,
"summary": {
"orders": len(active), "materials": len(mats),
"routings": len(routes), "equipment": len(equip), "bom": len(bom),
"ready": s.get("ready", 0), "blocked": s.get("blocked", 0),
},
"canSchedule": s.get("ready", 0) > 0 and not ready.get("globalIssues"),
"markdown": "\n".join(md_lines),
}