1217 lines
52 KiB
Python
1217 lines
52 KiB
Python
# ============================================================
|
||
# 项目深度分析(moduleId: domain-project-analyze, 可重生 ✅)
|
||
# Pi 已结构化的工程目录 Excel/SQL → 写入当前项目世界 → 明细表 + 齐备度 + 知识库补洞 plan
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import json
|
||
import os
|
||
import re
|
||
from typing import Any
|
||
|
||
from server.aps_domain.readiness import check_readiness
|
||
|
||
World = dict[str, Any]
|
||
|
||
_TABLE_EXT_RE = re.compile(r"\.(xlsx|xlsm|csv|txt)$", re.I)
|
||
_INPUT_EXT_RE = re.compile(r"\.(xlsx|xlsm|csv|txt|sql)$", re.I)
|
||
|
||
|
||
def _file_sha256(path: str) -> str:
|
||
"""资料文件指纹:采用卡冻结来源文件,批准时校验同一份文件未变化。"""
|
||
from server.agent_core.fallback_highrisk import file_sha256
|
||
|
||
return str(file_sha256(path) or "")
|
||
|
||
|
||
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 _project_ctx_readonly(session_id: str | None) -> tuple[dict | None, str]:
|
||
"""Resolve session -> project without ProjectStore seeding writes.
|
||
|
||
解析顺序:SQLite 只读直查(真实部署主路径)→ 只读 JSON 快照 →
|
||
注入/内存态 ProjectStore(单元测试与桌面内存态)。前两条不写任何东西;
|
||
最后一条只用于直查拿不到项目的场景,避免只读分析在内存态下直接失效。
|
||
"""
|
||
sid = str(session_id or "").strip()
|
||
if not sid:
|
||
return None, ""
|
||
try:
|
||
from server.auth.context import get_identity
|
||
|
||
identity = get_identity()
|
||
except Exception:
|
||
return None, ""
|
||
|
||
try:
|
||
import sqlite3
|
||
|
||
from sqlalchemy.engine import make_url
|
||
|
||
from server.db.database import _database_url
|
||
|
||
url = make_url(_database_url())
|
||
if url.get_backend_name() == "sqlite" and url.database:
|
||
db_path = os.path.abspath(str(url.database))
|
||
if os.path.isfile(db_path):
|
||
uri = "file:" + db_path.replace("\\", "/") + "?mode=ro"
|
||
connection = sqlite3.connect(uri, uri=True)
|
||
connection.row_factory = sqlite3.Row
|
||
try:
|
||
row = connection.execute(
|
||
"SELECT p.id, p.name, p.work_dir "
|
||
"FROM aps_chat_sessions s "
|
||
"JOIN aps_workspace_projects p "
|
||
"ON p.id = s.project_id AND p.tenant_uuid = s.tenant_uuid "
|
||
"JOIN aps_project_members m "
|
||
"ON m.project_id = p.id AND m.tenant_uuid = p.tenant_uuid "
|
||
"WHERE s.id = ? AND s.tenant_uuid = ? AND s.scope = 'project' "
|
||
"AND s.deleted = 0 AND p.deleted = 0 "
|
||
"AND m.user_id = ? AND m.status = 'active' AND m.deleted = 0 "
|
||
"LIMIT 1",
|
||
(sid, identity.tenant_uuid, identity.user_id),
|
||
).fetchone()
|
||
finally:
|
||
connection.close()
|
||
if row is not None:
|
||
project = {
|
||
"id": row["id"],
|
||
"name": row["name"],
|
||
"workDir": row["work_dir"],
|
||
}
|
||
return project, str(row["work_dir"] or "").strip()
|
||
except Exception:
|
||
pass
|
||
|
||
legacy_path = os.environ.get("APS_PROJECTS_PATH") or ""
|
||
try:
|
||
if legacy_path and os.path.isfile(legacy_path):
|
||
with open(legacy_path, "r", encoding="utf-8") as source:
|
||
legacy = json.load(source)
|
||
sessions = {
|
||
str(row.get("id")): row
|
||
for row in (legacy.get("sessions") or [])
|
||
if isinstance(row, dict)
|
||
}
|
||
projects = {
|
||
str(row.get("id")): row
|
||
for row in (legacy.get("projects") or [])
|
||
if isinstance(row, dict)
|
||
}
|
||
session = sessions.get(sid) or {}
|
||
project = projects.get(str(session.get("projectId") or "")) or None
|
||
if project:
|
||
return project, str(project.get("workDir") or "").strip()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from server.state.projects import get_project_store
|
||
|
||
snap = get_project_store().snapshot(include_messages=False)
|
||
sessions = {str(row.get("id")): row for row in (snap.get("sessions") or [])
|
||
if isinstance(row, dict)}
|
||
projects = {str(row.get("id")): row for row in (snap.get("projects") or [])
|
||
if isinstance(row, dict)}
|
||
session = sessions.get(sid) or {}
|
||
project = projects.get(str(session.get("projectId") or "")) or None
|
||
if project:
|
||
return project, str(project.get("workDir") or "").strip()
|
||
except Exception:
|
||
pass
|
||
return None, ""
|
||
|
||
|
||
def _analysis_error(message: str, *, read_only: bool = False) -> dict[str, Any]:
|
||
return {
|
||
"ok": False,
|
||
"error": message,
|
||
"projectName": None,
|
||
"workDir": "",
|
||
"sources": [],
|
||
"sourcePaths": [],
|
||
"applied": {},
|
||
"owner": None,
|
||
"sqlPreview": None,
|
||
"folder": None,
|
||
"orders": [],
|
||
"materials": [],
|
||
"routings": [],
|
||
"equipment": [],
|
||
"kbActions": [],
|
||
"plan": [],
|
||
"readiness": {"summary": {}},
|
||
"summary": {
|
||
"orders": 0, "materials": 0, "routings": 0,
|
||
"equipment": 0, "bom": 0, "ready": 0, "blocked": 0,
|
||
},
|
||
"canSchedule": False,
|
||
"readOnly": read_only,
|
||
"pendingAdoption": {
|
||
"mode": "none", "filename": "", "sourcePaths": [],
|
||
"batches": [], "counts": {},
|
||
},
|
||
"markdown": message,
|
||
"knowledgeIngest": {},
|
||
}
|
||
|
||
|
||
def _resolve_input_path(input_path: str | None) -> tuple[str, str | None]:
|
||
"""确定性校验 Pi 显式传入的文件/目录路径,不解析任何原始问句。"""
|
||
raw = str(input_path or "").strip()
|
||
if not raw:
|
||
return "", None
|
||
candidate = os.path.abspath(raw)
|
||
if not os.path.exists(candidate):
|
||
return "", f"指定的资料路径不存在:{raw}。请核对路径后重新提供 input_path。"
|
||
if os.path.isfile(candidate):
|
||
if not _INPUT_EXT_RE.search(candidate):
|
||
return "", "指定文件类型不支持:仅接受 xlsx、xlsm、csv、txt 或 sql。"
|
||
return candidate, None
|
||
if os.path.isdir(candidate):
|
||
return candidate, None
|
||
return "", "指定路径既不是文件也不是目录,请重新提供有效的 input_path。"
|
||
|
||
|
||
def _direct_table_report(
|
||
world: World,
|
||
paths: list[str],
|
||
*,
|
||
project_name: str,
|
||
work_dir: str,
|
||
) -> dict[str, Any]:
|
||
"""直接路径旁路:复用 preview_file,但不依赖工作区里的 workDir。"""
|
||
from server.aps_domain.folder_pack import KIND_CN
|
||
from server.aps_domain.importers import preview_file
|
||
|
||
required = ("orders", "materials", "routing", "equipment")
|
||
files: list[dict[str, Any]] = []
|
||
batches: list[dict[str, Any]] = []
|
||
kind_counts: dict[str, int] = {}
|
||
for path in paths:
|
||
name = os.path.basename(path)
|
||
try:
|
||
with open(path, "rb") as source:
|
||
preview = preview_file(name, source.read(), world, soft=True)
|
||
file_batches = preview.get("batches") or []
|
||
ok_count = int(preview.get("totalOk") or 0)
|
||
error_count = int(preview.get("totalErrors") or 0)
|
||
primary_kind = next(
|
||
(str(batch.get("kind")) for batch in file_batches if batch.get("okCount")),
|
||
str((file_batches[0] if file_batches else {}).get("kind") or ""),
|
||
)
|
||
field_map = next(
|
||
(list(batch.get("fieldMap") or []) for batch in file_batches if batch.get("fieldMap")),
|
||
[],
|
||
)
|
||
errors: list[str] = []
|
||
warnings: list[str] = []
|
||
headers: list[str] = []
|
||
samples: list[str] = []
|
||
sheets: list[dict[str, Any]] = []
|
||
for batch in file_batches:
|
||
kind = str(batch.get("kind") or primary_kind or "materials")
|
||
count = int(batch.get("okCount") or 0)
|
||
if count:
|
||
kind_counts[kind] = kind_counts.get(kind, 0) + count
|
||
errors.extend(str(item) for item in (batch.get("errors") or [])[:2])
|
||
warnings.extend(str(item) for item in (batch.get("warnings") or [])[:2])
|
||
if not headers:
|
||
headers = [str(item) for item in (batch.get("headersRaw") or []) if item][:8]
|
||
for row in (batch.get("okRows") or [])[:1]:
|
||
if isinstance(row, dict):
|
||
samples.append(";".join(f"{k}={v}" for k, v in list(row.items())[:6]))
|
||
sheets.append({
|
||
"sheet": batch.get("sheet"), "kind": kind,
|
||
"kindCn": KIND_CN.get(kind, kind), "okCount": count,
|
||
"errorCount": int(batch.get("errorCount") or 0),
|
||
})
|
||
if count:
|
||
batches.append({
|
||
"kind": kind, "sheet": batch.get("sheet") or name,
|
||
"okRows": batch.get("okRows") or [], "okCount": count,
|
||
"errorCount": int(batch.get("errorCount") or 0),
|
||
"errors": (batch.get("errors") or [])[:8],
|
||
"warnings": (batch.get("warnings") or [])[:5],
|
||
"fieldMap": batch.get("fieldMap") or [], "sourceFile": name,
|
||
})
|
||
fmap_text = "、".join(
|
||
f"{item.get('source')}→{item.get('target')}" for item in field_map
|
||
)
|
||
issue = (errors or warnings or ([f"字段:{fmap_text}"] if fmap_text else []))
|
||
files.append({
|
||
"name": name, "size": os.path.getsize(path),
|
||
"status": "ok" if ok_count and not error_count else ("warn" if ok_count else "fail"),
|
||
"kind": primary_kind, "kindCn": KIND_CN.get(primary_kind, primary_kind or "未识别"),
|
||
"okCount": ok_count, "errorCount": error_count,
|
||
"errors": errors, "warnings": warnings, "headers": headers,
|
||
"samples": samples, "sheets": sheets, "fieldMap": field_map,
|
||
"fieldMapText": fmap_text, "issue": issue[0] if issue else "",
|
||
})
|
||
except Exception as exc: # noqa: BLE001
|
||
files.append({
|
||
"name": name, "size": os.path.getsize(path) if os.path.isfile(path) else 0,
|
||
"status": "fail", "kind": "", "kindCn": "读取失败",
|
||
"okCount": 0, "errorCount": 1, "errors": [str(exc)],
|
||
"warnings": [], "headers": [], "samples": [], "sheets": [],
|
||
"fieldMap": [], "fieldMapText": "", "issue": str(exc),
|
||
})
|
||
coverage = {kind: kind_counts.get(kind, 0) > 0 for kind in required}
|
||
coverage_detail = [{
|
||
"key": kind, "label": KIND_CN[kind], "ok": coverage[kind],
|
||
"count": kind_counts.get(kind, 0), "fieldMap": [],
|
||
"note": (f"读到 {kind_counts[kind]} 行" if coverage[kind] else "还没有有效数据"),
|
||
} for kind in required]
|
||
missing = [KIND_CN[kind] for kind in required if not coverage[kind]]
|
||
return {
|
||
"ok": True, "projectName": project_name, "workDir": work_dir,
|
||
"files": files, "coverage": coverage, "coverageDetail": coverage_detail,
|
||
"kindCounts": kind_counts, "missing": missing, "softMissing": [],
|
||
"canSchedule": not missing, "batches": batches,
|
||
"totalOk": sum(int(item.get("okCount") or 0) for item in files),
|
||
"totalErrors": sum(int(item.get("errorCount") or 0) for item in files),
|
||
}
|
||
|
||
|
||
def _preview_apply_template(
|
||
world: World,
|
||
template: dict[str, Any],
|
||
product_code: str,
|
||
product_name: str,
|
||
) -> None:
|
||
"""Apply a routing template to a detached preview world without persistence."""
|
||
world["flexRoutings"] = [
|
||
row for row in (world.get("flexRoutings") or [])
|
||
if row.get("productCode") != product_code
|
||
]
|
||
operations = {
|
||
row.get("code"): row for row in (world.get("flexOperations") or [])
|
||
if isinstance(row, dict)
|
||
}
|
||
equipment = world.get("flexEquipment") or []
|
||
for step in template.get("steps") or []:
|
||
code = step.get("operationCode")
|
||
if not code:
|
||
continue
|
||
if code not in operations:
|
||
world.setdefault("flexOperations", []).append({
|
||
"code": code,
|
||
"name": step.get("operationName") or code,
|
||
"isBottleneck": False,
|
||
"changeoverMin": step.get("changeoverMin") or 0,
|
||
})
|
||
operations[code] = world["flexOperations"][-1]
|
||
if not any(code in (row.get("capabilities") or []) for row in equipment):
|
||
for row in equipment:
|
||
if row.get("status") == "RUNNING":
|
||
row.setdefault("capabilities", []).append(code)
|
||
row.setdefault("opStdTime", {})[code] = step.get("stdMinDefault") or 1.0
|
||
world.setdefault("flexRoutings", []).append({
|
||
"productCode": product_code,
|
||
"productName": product_name or product_code,
|
||
"seq": step.get("seq"),
|
||
"operationCode": code,
|
||
"requireMold": False,
|
||
"stdTimePerUnit": step.get("stdMinDefault") or 1.0,
|
||
"stdTimeSource": "模板",
|
||
})
|
||
template_ops = [row.get("operationCode") for row in (template.get("steps") or [])]
|
||
teams = world.get("flexTeams") or []
|
||
generic = next((team for team in teams if team.get("code") == "T-TPL"), None)
|
||
if generic is None and teams is not None:
|
||
generic = {
|
||
"code": "T-TPL", "name": "模板工序班组(推断)",
|
||
"memberCount": 4, "supportOps": [], "skillLevel": "L2",
|
||
}
|
||
world.setdefault("flexTeams", []).append(generic)
|
||
if generic is not None:
|
||
for code in template_ops:
|
||
if code and code not in generic.setdefault("supportOps", []):
|
||
generic["supportOps"].append(code)
|
||
if not any(row.get("code") == product_code for row in (world.get("flexMaterials") or [])):
|
||
world.setdefault("flexMaterials", []).append({
|
||
"code": product_code, "name": product_name or product_code,
|
||
"type": "FINISHED_PRODUCT", "unit": "件",
|
||
"stock": 0, "inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0,
|
||
})
|
||
|
||
|
||
def _template_catalog_readonly() -> list[dict[str, Any]]:
|
||
"""Load the routing-template catalog without initializing or seeding storage."""
|
||
try:
|
||
from server.knowledge.routing_templates import _BUILTIN
|
||
except Exception:
|
||
return []
|
||
|
||
catalog: list[dict[str, Any]] = []
|
||
for item in _BUILTIN:
|
||
steps = []
|
||
for seq, code, name, equipment_type, low, high, setup, changeover in (item.get("steps") or []):
|
||
steps.append({
|
||
"seq": seq,
|
||
"operationCode": code,
|
||
"operationName": name,
|
||
"equipmentType": equipment_type,
|
||
"stdMinLow": low,
|
||
"stdMinHigh": high,
|
||
"stdMinDefault": round((float(low) + float(high)) / 2, 1),
|
||
"setupMin": setup,
|
||
"changeoverMin": changeover,
|
||
})
|
||
catalog.append({
|
||
"code": item.get("code"),
|
||
"name": item.get("name"),
|
||
"industry": "machining",
|
||
"category": item.get("category"),
|
||
"keywords": str(item.get("keywords") or "").split(","),
|
||
"description": item.get("description"),
|
||
"assetId": "",
|
||
"source": "builtin",
|
||
"steps": steps,
|
||
})
|
||
|
||
# Overlay tenant/platform templates when a SQLite database already exists.
|
||
# Opening it in mode=ro avoids creating a database or running schema seeding.
|
||
try:
|
||
import sqlite3
|
||
|
||
from sqlalchemy.engine import make_url
|
||
|
||
from server.auth.context import get_identity
|
||
from server.db.database import _database_url
|
||
|
||
url = make_url(_database_url())
|
||
if url.get_backend_name() != "sqlite" or not url.database:
|
||
return catalog
|
||
db_path = os.path.abspath(str(url.database))
|
||
if not os.path.isfile(db_path):
|
||
return catalog
|
||
|
||
tenants = ("platform", get_identity().tenant_uuid or "platform")
|
||
placeholders = ",".join("?" for _ in tenants)
|
||
uri = "file:" + db_path.replace("\\", "/") + "?mode=ro"
|
||
connection = sqlite3.connect(uri, uri=True)
|
||
connection.row_factory = sqlite3.Row
|
||
try:
|
||
rows = connection.execute(
|
||
"SELECT id, tenant_uuid, code, name, industry, category, keywords, "
|
||
"description, asset_id, source FROM routing_templates "
|
||
f"WHERE deleted = 0 AND tenant_uuid IN ({placeholders}) ORDER BY id",
|
||
tenants,
|
||
).fetchall()
|
||
if not rows:
|
||
return catalog
|
||
|
||
template_ids = [int(row["id"]) for row in rows]
|
||
id_placeholders = ",".join("?" for _ in template_ids)
|
||
step_rows = connection.execute(
|
||
"SELECT template_id, seq, operation_code, operation_name, equipment_type, "
|
||
"std_min_low, std_min_high, setup_min, changeover_min "
|
||
"FROM routing_template_steps "
|
||
f"WHERE deleted = 0 AND template_id IN ({id_placeholders}) ORDER BY seq",
|
||
template_ids,
|
||
).fetchall()
|
||
steps_by_template: dict[int, list[dict[str, Any]]] = {}
|
||
for step in step_rows:
|
||
low = float(step["std_min_low"] or 0)
|
||
high = float(step["std_min_high"] or 0)
|
||
steps_by_template.setdefault(int(step["template_id"]), []).append({
|
||
"seq": step["seq"],
|
||
"operationCode": step["operation_code"],
|
||
"operationName": step["operation_name"],
|
||
"equipmentType": step["equipment_type"],
|
||
"stdMinLow": low,
|
||
"stdMinHigh": high,
|
||
"stdMinDefault": round((low + high) / 2, 1),
|
||
"setupMin": step["setup_min"],
|
||
"changeoverMin": step["changeover_min"],
|
||
})
|
||
|
||
merged = {str(item.get("code")): item for item in catalog}
|
||
order = [str(item.get("code")) for item in catalog]
|
||
for row in rows:
|
||
code = str(row["code"] or "")
|
||
if not code:
|
||
continue
|
||
merged[code] = {
|
||
"code": code,
|
||
"name": row["name"],
|
||
"industry": row["industry"],
|
||
"category": row["category"],
|
||
"keywords": str(row["keywords"] or "").split(","),
|
||
"description": row["description"],
|
||
"assetId": row["asset_id"],
|
||
"source": row["source"],
|
||
"steps": steps_by_template.get(int(row["id"]), []),
|
||
}
|
||
if code not in order:
|
||
order.append(code)
|
||
return [merged[code] for code in order]
|
||
finally:
|
||
connection.close()
|
||
except Exception:
|
||
return catalog
|
||
|
||
|
||
def _recommend_templates_readonly(text: str, top_k: int = 3) -> list[dict[str, Any]]:
|
||
"""Match templates against an immutable preview catalog without seeding."""
|
||
text = (text or "").strip()
|
||
scored: list[tuple[float, dict[str, Any]]] = []
|
||
for template in _template_catalog_readonly():
|
||
score = 0.0
|
||
for keyword in template.get("keywords") or []:
|
||
if keyword and keyword in text:
|
||
score += 2.0
|
||
if template.get("category") and template["category"] in text:
|
||
score += 1.5
|
||
for step in template.get("steps") or []:
|
||
if step.get("operationName") and step["operationName"] in text:
|
||
score += 0.5
|
||
if score > 0:
|
||
scored.append((score, template))
|
||
scored.sort(key=lambda item: -item[0])
|
||
return [dict(template, matchScore=score) for score, template in scored[:top_k]]
|
||
|
||
|
||
def _fill_from_knowledge(
|
||
world: World,
|
||
product_codes: list[str],
|
||
*,
|
||
apply: bool = True,
|
||
) -> 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:
|
||
if apply:
|
||
recs = recommend_templates(f"{pc} {name}") or []
|
||
else:
|
||
recs = _recommend_templates_readonly(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:
|
||
if apply:
|
||
apply_template_to_product(world, code, pc)
|
||
else:
|
||
_preview_apply_template(world, tpl, pc, name)
|
||
actions.append({
|
||
"type": "template_applied", "productCode": pc,
|
||
"template": code, "templateName": tpl.get("name"),
|
||
"detail": (
|
||
f"产品 {pc} 缺工艺 → 已用知识库模板「{tpl.get('name') or code}」生成"
|
||
if apply else
|
||
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 _preview_replace_flex(
|
||
world: World,
|
||
values: dict[str, Any],
|
||
*,
|
||
ensure_calendar: bool = False,
|
||
) -> dict[str, int]:
|
||
"""Load an already parsed flex snapshot into a detached preview world."""
|
||
fields = (
|
||
("flexOrders", ("flexOrders", "orders")),
|
||
("flexMaterials", ("flexMaterials", "materials")),
|
||
("flexEquipment", ("flexEquipment", "equipment")),
|
||
("flexRoutings", ("flexRoutings", "routing")),
|
||
("flexBom", ("flexBom", "bom")),
|
||
("flexOperations", ("flexOperations", "operations")),
|
||
("flexZones", ("flexZones", "zones")),
|
||
("flexTeams", ("flexTeams", "teams")),
|
||
("flexPersonnel", ("flexPersonnel", "personnel")),
|
||
("flexFactoryResources", ("flexFactoryResources", "factoryResources")),
|
||
("flexPartners", ("flexPartners", "partners")),
|
||
("flexWip", ("flexWip", "wip")),
|
||
)
|
||
summary: dict[str, int] = {}
|
||
for target, sources in fields:
|
||
source = next((name for name in sources if name in values), None)
|
||
if source is None:
|
||
continue
|
||
rows = copy.deepcopy(values.get(source) or [])
|
||
world[target] = rows
|
||
if rows:
|
||
summary[target] = len(rows)
|
||
if ensure_calendar and not world.get("flexCalendar"):
|
||
world["flexCalendar"] = [{
|
||
"shiftCode": "D", "startTime": "08:00", "endTime": "17:00",
|
||
"breaks": [{"start": "12:00", "end": "13:00"}],
|
||
"workdays": [1, 2, 3, 4, 5],
|
||
}]
|
||
summary["calendar"] = 1
|
||
return summary
|
||
|
||
|
||
def _preview_apply_batches(world: World, batches: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""Apply reviewed import rows to a detached preview world for read-only analysis."""
|
||
profile_batches = [batch for batch in batches if batch.get("sourceProfile")]
|
||
if profile_batches:
|
||
if len(profile_batches) != len(batches):
|
||
raise ValueError("完整工作簿与其他导入批次不能混合确认,请分别导入")
|
||
from server.importers.planning_workbook import apply_planning_batches
|
||
|
||
return apply_planning_batches(world, profile_batches)
|
||
|
||
summary: dict[str, int] = {}
|
||
|
||
def _next_id(table: str) -> int:
|
||
rows = world.get(table) or []
|
||
return max(
|
||
(row.get("id", 0) for row in rows if isinstance(row.get("id"), int)),
|
||
default=0,
|
||
) + 1
|
||
|
||
def _upsert(table: str, row: dict[str, Any], keys: tuple[str, ...]) -> None:
|
||
rows = world.setdefault(table, [])
|
||
existing = next(
|
||
(
|
||
item for item in rows
|
||
if all(item.get(key) == row.get(key) for key in keys)
|
||
),
|
||
None,
|
||
)
|
||
if existing is not None:
|
||
existing.update(row)
|
||
else:
|
||
rows.append({"id": _next_id(table), **row})
|
||
|
||
for batch in batches:
|
||
kind = batch.get("kind")
|
||
for row in batch.get("okRows") or []:
|
||
if kind == "orders":
|
||
_upsert("flexOrders", {
|
||
"orderNo": row.get("orderNo"),
|
||
"productCode": row.get("productCode"),
|
||
"quantity": row.get("quantity"),
|
||
"dueDate": row.get("deliveryDate"),
|
||
"priority": row.get("priority", 5),
|
||
"customerName": row.get("customerName") or "现场客户",
|
||
"customerLevel": row.get("customerLevel") or "B",
|
||
"isRush": bool(row.get("isRush")),
|
||
"status": "RELEASED",
|
||
}, ("orderNo",))
|
||
summary["flexOrders"] = summary.get("flexOrders", 0) + 1
|
||
elif kind == "materials":
|
||
_upsert("flexMaterials", dict(row), ("code",))
|
||
summary["flexMaterials"] = summary.get("flexMaterials", 0) + 1
|
||
elif kind == "calendar":
|
||
_upsert("flexCalendar", dict(row), ("shiftCode",))
|
||
summary["flexCalendar"] = summary.get("flexCalendar", 0) + 1
|
||
elif kind == "equipment":
|
||
_upsert("flexEquipment", dict(row), ("code",))
|
||
summary["flexEquipment"] = summary.get("flexEquipment", 0) + 1
|
||
elif kind == "molds":
|
||
_upsert("flexMolds", dict(row), ("code",))
|
||
summary["flexMolds"] = summary.get("flexMolds", 0) + 1
|
||
elif kind == "operations":
|
||
_upsert("flexOperations", dict(row), ("code",))
|
||
summary["flexOperations"] = summary.get("flexOperations", 0) + 1
|
||
elif kind == "zones":
|
||
_upsert("flexZones", dict(row), ("code",))
|
||
summary["flexZones"] = summary.get("flexZones", 0) + 1
|
||
elif kind == "routing":
|
||
_upsert("flexRoutings", dict(row), ("productCode", "seq"))
|
||
summary["flexRoutings"] = summary.get("flexRoutings", 0) + 1
|
||
elif kind == "bom":
|
||
_upsert("flexBom", dict(row), ("productCode", "materialCode"))
|
||
summary["flexBom"] = summary.get("flexBom", 0) + 1
|
||
return {"summary": summary, "total": sum(summary.values())}
|
||
|
||
|
||
def analyze_project_deep(
|
||
world: World,
|
||
session_id: str | None = None,
|
||
*,
|
||
apply_sql: bool = True,
|
||
query: str = "",
|
||
input_path: str | None = None,
|
||
next_id=None,
|
||
apply: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""深度分析显式资料路径或当前项目目录;query 仅为兼容旧调用的弃用参数。"""
|
||
del query # 原始自然语言不参与路径、实体或数据语义推断。
|
||
try:
|
||
from server.agent_core.progress import emit_thinking
|
||
except Exception:
|
||
def emit_thinking(*_a, **_k): # type: ignore
|
||
return None
|
||
|
||
explicit_input, input_error = _resolve_input_path(input_path)
|
||
if input_error:
|
||
return _analysis_error(input_error, read_only=not apply)
|
||
proj, work = _project_ctx(session_id) if apply else _project_ctx_readonly(session_id)
|
||
if explicit_input:
|
||
work = explicit_input if os.path.isdir(explicit_input) else os.path.dirname(explicit_input)
|
||
if proj is None:
|
||
proj = {
|
||
"name": os.path.basename(work.rstrip("\\/")) or "当前项目",
|
||
"workDir": work,
|
||
}
|
||
if not work or not os.path.isdir(work):
|
||
return _analysis_error(
|
||
"请明确要分析的资料路径:提供 input_path,或先选择带有工程目录的项目。"
|
||
"不能从原始问句推断路径。",
|
||
read_only=not apply,
|
||
)
|
||
analysis_world = world if apply else copy.deepcopy(world)
|
||
sources: list[str] = []
|
||
source_paths: list[str] = []
|
||
sql_preview: dict[str, Any] | None = None
|
||
applied: dict[str, int] = {}
|
||
kb_actions: list[dict[str, Any]] = []
|
||
mom_applied = False
|
||
mom_path = ""
|
||
|
||
def apply_folder_batches(report: dict[str, Any] | None) -> None:
|
||
batches = (report or {}).get("batches") or []
|
||
if not batches or next_id is None:
|
||
return
|
||
if apply:
|
||
from server.aps_domain.importers import apply_import_commit
|
||
|
||
result = apply_import_commit(analysis_world, next_id, batches)
|
||
else:
|
||
result = _preview_apply_batches(analysis_world, batches)
|
||
# apply_import_commit returns {summary: {...}, total: N}; keep accepting
|
||
# the historical flat counter shape for callers that still provide it.
|
||
counts = (
|
||
result.get("summary")
|
||
if isinstance(result, dict) and isinstance(result.get("summary"), dict)
|
||
else result
|
||
) or {}
|
||
for key, value in counts.items():
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
applied[key] = applied.get(key, 0) + int(value)
|
||
|
||
emit_thinking("定位工程目录", (proj or {}).get("name") or "当前项目", pct=2)
|
||
if work and os.path.isdir(work):
|
||
names = ([os.path.basename(explicit_input)] if os.path.isfile(explicit_input)
|
||
else 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 _TABLE_EXT_RE.search(n) and not n.startswith("~$")]
|
||
source_paths = [
|
||
os.path.join(work, name) for name in (*sqls, *xls)
|
||
if os.path.isfile(os.path.join(work, name))
|
||
]
|
||
folder = None
|
||
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)
|
||
# Read-only analysis must still materialize SQL into the detached
|
||
# preview world when callers disable both write switches.
|
||
if sql_preview.get("flex") and (apply_sql or not apply):
|
||
emit_thinking(
|
||
"写入项目世界" if apply else "预演项目世界",
|
||
"订单 / 物料 / 工艺 / 设备",
|
||
pct=93,
|
||
)
|
||
# SQL 包是工程目录真相源,始终整包覆盖,避免旧错误工艺残留
|
||
if apply:
|
||
applied = apply_sql_pack_to_world(
|
||
analysis_world, sql_preview["flex"], replace=True)
|
||
else:
|
||
applied = _preview_replace_flex(
|
||
analysis_world,
|
||
sql_preview["flex"],
|
||
ensure_calendar=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")
|
||
elif xls:
|
||
# 无 SQL 时:识别 MOM 主数据收集表并写入世界(否则深度分析永远是 0)
|
||
try:
|
||
from server.importers.mom_pack import (
|
||
apply_mom_pack_to_world,
|
||
is_mom_workbook,
|
||
mom_pack_is_adoptable,
|
||
mom_pack_row_count,
|
||
parse_mom_workbook,
|
||
)
|
||
mom_files = [
|
||
n for n in xls
|
||
if is_mom_workbook(os.path.join(work, n))
|
||
]
|
||
# 文件名含 MOM/主数据 优先;否则对全部 xlsx 试探
|
||
candidates = mom_files or xls
|
||
for n in candidates:
|
||
path = os.path.join(work, n)
|
||
if not is_mom_workbook(path):
|
||
continue
|
||
pack = parse_mom_workbook(path)
|
||
if not mom_pack_is_adoptable(pack):
|
||
# 结构证据(≥2 张模型表)或主干行(物料/BOM/订单/工艺路线)
|
||
# 不足:采用即整表替换,这里不作为 MOM 来源,避免清空既有主数据。
|
||
emit_thinking("MOM 主数据表不足以采用", n, status="warn")
|
||
continue
|
||
sources.append(f"MOM:{n}")
|
||
emit_thinking("识别 MOM 主数据表", n, pct=90)
|
||
if apply:
|
||
applied = apply_mom_pack_to_world(analysis_world, pack, replace=True)
|
||
else:
|
||
applied = _preview_replace_flex(analysis_world, pack)
|
||
result = {"stats": pack.get("stats") or {}}
|
||
mom_applied = True
|
||
mom_path = path
|
||
emit_thinking(
|
||
"MOM 入库完成",
|
||
"、".join(f"{k}{v}" for k, v in applied.items()) or "无新增",
|
||
status="done", pct=95,
|
||
)
|
||
stats = result.get("stats") or {}
|
||
if stats.get("note"):
|
||
sources.append(str(stats["note"]))
|
||
break
|
||
except Exception as exc:
|
||
emit_thinking("MOM Excel 解析失败", str(exc), status="warn")
|
||
if not mom_applied:
|
||
sources.append(f"Excel×{len(xls)}")
|
||
try:
|
||
emit_thinking("解析并写入 Excel/CSV", f"{len(xls)} 个表格", pct=96)
|
||
if explicit_input:
|
||
folder = _direct_table_report(
|
||
analysis_world,
|
||
[os.path.join(work, name) for name in xls],
|
||
project_name=(proj or {}).get("name") or "当前项目",
|
||
work_dir=work,
|
||
)
|
||
else:
|
||
from server.aps_domain.folder_pack import analyze_work_dir
|
||
folder = analyze_work_dir(analysis_world, session_id, apply_sql=apply)
|
||
apply_folder_batches(folder)
|
||
except Exception as exc:
|
||
if folder is None:
|
||
folder = {
|
||
"ok": False,
|
||
"projectName": (proj or {}).get("name"),
|
||
"workDir": work,
|
||
"files": [], "coverage": {}, "missing": [],
|
||
"canSchedule": False, "batches": [],
|
||
"totalOk": 0, "totalErrors": 1,
|
||
"error": str(exc),
|
||
}
|
||
else:
|
||
folder["error"] = str(exc)
|
||
else:
|
||
try:
|
||
if explicit_input:
|
||
folder = _direct_table_report(
|
||
analysis_world,
|
||
[os.path.join(work, name) for name in xls],
|
||
project_name=(proj or {}).get("name") or "当前项目",
|
||
work_dir=work,
|
||
)
|
||
else:
|
||
from server.aps_domain.folder_pack import analyze_work_dir
|
||
folder = analyze_work_dir(analysis_world, session_id, apply_sql=apply)
|
||
except Exception as exc:
|
||
if folder is None:
|
||
folder = {
|
||
"ok": False,
|
||
"projectName": (proj or {}).get("name"),
|
||
"workDir": work,
|
||
"files": [], "coverage": {}, "missing": [],
|
||
"canSchedule": False, "batches": [],
|
||
"totalOk": 0, "totalErrors": 1,
|
||
"error": str(exc),
|
||
}
|
||
else:
|
||
folder["error"] = str(exc)
|
||
else:
|
||
folder = None
|
||
|
||
if sqls and xls:
|
||
sources.append(f"Excel×{len(xls)}")
|
||
try:
|
||
emit_thinking("核对 Excel/CSV", f"{len(xls)} 个表格", pct=96)
|
||
if explicit_input:
|
||
folder = _direct_table_report(
|
||
analysis_world,
|
||
[os.path.join(work, name) for name in xls],
|
||
project_name=(proj or {}).get("name") or "当前项目",
|
||
work_dir=work,
|
||
)
|
||
else:
|
||
from server.aps_domain.folder_pack import analyze_work_dir
|
||
folder = analyze_work_dir(analysis_world, session_id, apply_sql=apply)
|
||
apply_folder_batches(folder)
|
||
except Exception as exc:
|
||
if folder is None:
|
||
folder = {
|
||
"ok": False,
|
||
"projectName": (proj or {}).get("name"),
|
||
"workDir": work,
|
||
"files": [], "coverage": {}, "missing": [],
|
||
"canSchedule": False, "batches": [],
|
||
"totalOk": 0, "totalErrors": 1,
|
||
"error": str(exc),
|
||
}
|
||
else:
|
||
folder["error"] = str(exc)
|
||
else:
|
||
folder = None
|
||
emit_thinking("未挂工程目录", "仅分析当前项目世界里的数据", status="warn", pct=10)
|
||
|
||
# 知识库补洞:缺工艺的产品
|
||
emit_thinking("检查齐备度与知识库", "缺工艺时自动匹配行业模板", pct=97)
|
||
active = [o for o in (analysis_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(analysis_world, pcs, apply=apply)
|
||
if kb_actions:
|
||
emit_thinking(
|
||
"知识库补洞",
|
||
f"{len(kb_actions)} 项(模板/占位/待客户补)",
|
||
status="done", pct=98,
|
||
)
|
||
|
||
ready = check_readiness(analysis_world)
|
||
emit_thinking("整理分析报告", f"订单 {len(active)} 张", status="done", pct=99)
|
||
mats = analysis_world.get("flexMaterials") or []
|
||
routes = analysis_world.get("flexRoutings") or []
|
||
equip = [e for e in (analysis_world.get("flexEquipment") or []) if e.get("status") == "RUNNING"]
|
||
bom = analysis_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 '当前项目世界(未挂工程目录文件)'}",
|
||
]
|
||
# 绑定当前登录用户
|
||
try:
|
||
from server.knowledge.ingest import bind_world_to_identity, ingest_project_analyze_report
|
||
owner_meta = bind_world_to_identity(analysis_world, sources=sources)
|
||
md_lines.append(
|
||
f"归属:{owner_meta.get('ownerFullname') or owner_meta.get('ownerUsername')} "
|
||
f"(用户 {owner_meta.get('ownerUserId')} · 租户 {str(owner_meta.get('tenantUuid') or '')[:8]}…)"
|
||
)
|
||
except Exception:
|
||
owner_meta = {}
|
||
ingest_project_analyze_report = None # type: ignore
|
||
|
||
if apply and applied:
|
||
md_lines.append(
|
||
"已入库:"
|
||
+ "、".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}")
|
||
|
||
def _adoption_counts(values: dict[str, Any] | None) -> dict[str, int]:
|
||
return {
|
||
str(key): int(value)
|
||
for key, value in (values or {}).items()
|
||
if isinstance(value, (int, float))
|
||
and not isinstance(value, bool)
|
||
and int(value) > 0
|
||
}
|
||
|
||
folder_batches = [
|
||
copy.deepcopy(batch)
|
||
for batch in ((folder or {}).get("batches") or [])
|
||
if batch.get("okRows")
|
||
]
|
||
sql_flex = (sql_preview or {}).get("flex") or {}
|
||
sql_available = bool(sql_flex.get("stats"))
|
||
if mom_applied and mom_path:
|
||
pending_adoption = {
|
||
"mode": "mom",
|
||
"filename": os.path.basename(mom_path),
|
||
"sourcePaths": [mom_path],
|
||
"batches": [],
|
||
"counts": _adoption_counts(applied),
|
||
# 出卡/执行两处都按这个口径复核,避免零业务行的 MOM 表升级成整表替换。
|
||
"businessRows": mom_pack_row_count(pack),
|
||
"adoptable": mom_pack_is_adoptable(pack),
|
||
}
|
||
if not apply:
|
||
# 只读分析不出卡不入库:把来源路径与指纹冻结给采用卡,批准后按同一份文件采用。
|
||
pending_adoption["applyPath"] = mom_path
|
||
pending_adoption["applySha256"] = _file_sha256(mom_path)
|
||
elif folder_batches:
|
||
batch_files = list(dict.fromkeys(
|
||
str(batch.get("sourceFile") or "")
|
||
for batch in folder_batches
|
||
if batch.get("sourceFile")
|
||
))
|
||
filename = batch_files[0] if len(batch_files) == 1 else ""
|
||
batch_file_set = set(batch_files)
|
||
selected_paths = [
|
||
path for path in source_paths
|
||
if os.path.basename(path) in batch_file_set
|
||
]
|
||
pending_adoption = {
|
||
"mode": "folder",
|
||
"filename": filename,
|
||
"sourcePaths": selected_paths or list(source_paths),
|
||
"batches": folder_batches,
|
||
"counts": _adoption_counts(applied) or {
|
||
kind: sum(
|
||
len(batch.get("okRows") or [])
|
||
for batch in folder_batches
|
||
if batch.get("kind") == kind
|
||
)
|
||
for kind in {str(batch.get("kind") or "") for batch in folder_batches}
|
||
if kind
|
||
},
|
||
}
|
||
elif sql_available:
|
||
sql_name = str((sql_preview or {}).get("filename") or "")
|
||
pending_adoption = {
|
||
"mode": "sql",
|
||
"filename": sql_name,
|
||
"sourcePaths": [
|
||
path for path in source_paths
|
||
if sql_name and os.path.basename(path) == sql_name
|
||
] or list(source_paths),
|
||
"batches": [],
|
||
"counts": _adoption_counts(applied),
|
||
}
|
||
if not apply:
|
||
# SQL 没有 okRows 批次:冻结已解析载荷,采用时整包写入而不是重读文件。
|
||
payload = copy.deepcopy(sql_flex)
|
||
if payload.get("stats"):
|
||
pending_adoption["sqlPayload"] = payload
|
||
else:
|
||
pending_adoption = {
|
||
"mode": "none", "filename": "", "sourcePaths": [],
|
||
"batches": [], "counts": {},
|
||
}
|
||
|
||
if not apply and pending_adoption["mode"] != "none":
|
||
md_lines.append("只读分析:资料尚未采用,确认后再写入项目数据。")
|
||
|
||
result = {
|
||
"ok": True,
|
||
"projectName": proj_name,
|
||
"workDir": work,
|
||
"sources": sources,
|
||
"sourcePaths": source_paths,
|
||
"applied": applied if apply else {},
|
||
"owner": {
|
||
"userId": owner_meta.get("ownerUserId"),
|
||
"username": owner_meta.get("ownerUsername"),
|
||
"fullname": owner_meta.get("ownerFullname"),
|
||
"tenantUuid": owner_meta.get("tenantUuid"),
|
||
} if owner_meta else None,
|
||
"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": folder,
|
||
"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"),
|
||
"readOnly": not apply,
|
||
"pendingAdoption": pending_adoption,
|
||
"markdown": "\n".join(md_lines),
|
||
}
|
||
|
||
# 分析结果写入当前租户知识库(平台知识模块,按登录租户隔离)
|
||
knowledge_ingest: dict[str, Any] = {}
|
||
if apply:
|
||
try:
|
||
has_data = bool(
|
||
result["summary"]["materials"]
|
||
or result["summary"]["orders"]
|
||
or result["summary"]["equipment"]
|
||
or result["summary"]["bom"]
|
||
or result.get("sourcePaths")
|
||
)
|
||
if ingest_project_analyze_report is not None and has_data:
|
||
knowledge_ingest = ingest_project_analyze_report(result, world=analysis_world)
|
||
if knowledge_ingest.get("assetId"):
|
||
md_lines.append("")
|
||
md_lines.append(
|
||
f"已写入知识库:《{knowledge_ingest.get('title')}》"
|
||
f"({knowledge_ingest.get('chunkCount')} 段,{knowledge_ingest.get('version')})"
|
||
)
|
||
result["markdown"] = "\n".join(md_lines)
|
||
except Exception as exc:
|
||
knowledge_ingest = {"error": str(exc)}
|
||
result["knowledgeIngest"] = knowledge_ingest
|
||
return result
|