This commit is contained in:
2026-07-30 10:30:50 +08:00
parent cf6d708566
commit 853c478657
85 changed files with 4711 additions and 1052 deletions
+1
View File
@@ -21,6 +21,7 @@ def _safe_include(module_path: str, label: str):
_safe_include("moldinsight.api.health_router", "健康检查")
_safe_include("moldinsight.api.upload_router", "上传")
_safe_include("moldinsight.api.batch_router", "批量")
_safe_include("moldinsight.api.task_router", "任务")
_safe_include("moldinsight.api.history_router", "历史")
_safe_include("moldinsight.api.debug_router", "调试")
+10 -4
View File
@@ -307,7 +307,7 @@ async def estimate_cost(
request: Request,
current_user: User = Depends(get_current_active_user),
):
"""LLM 模具成本估算(P2-2:真 AI 落地,需启用 LLM)"""
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
body = await request.json()
task_id = body.get("task_id")
if not task_id:
@@ -323,11 +323,17 @@ async def estimate_cost(
"geometry_data": task_data.get("geometry_data", {}),
"metadata": {"selected_material": task_data.get("material")},
}
# 优先使用 LLM
from moldinsight.services.llm_service import llm_service
result = await llm_service.estimate_cost(analysis_result, detailed_context)
if result is None:
raise HTTPException(503, "成本估算不可用(LLM 未启用或生成失败)")
return {"status": "success", "data": result}
if result is not None:
result["source"] = "ai"
return {"status": "success", "data": result}
# LLM 未启用或失败,降级为规则估算
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
return {"status": "success", "data": rules_result}
@router.post("/design-cam")
+226
View File
@@ -0,0 +1,226 @@
"""
moldinsight/api/batch_router.py — 批量分析端点
- POST /api/batch-upload 批量上传多文件,返回 batch_id + 各 task_id
- GET /api/batch/{batch_id} 聚合查询批量任务进度
"""
import uuid
from datetime import datetime
from typing import List, Dict, Any
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user
from shared.models.database import User
from shared.models.schemas import ProcessingStatus, create_task_info
from shared.services.redis_task_manager import redis_task_manager
from shared.utils.file_handler import FileHandler
from shared.utils.logger import get_logger
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
try:
from celery_tasks import process_stp_task
_use_celery = True
except ImportError:
process_stp_task = None
_use_celery = False
logger = get_logger(__name__)
router = APIRouter()
file_handler = FileHandler()
# ─── 批量元数据 Redis key 约定 ──────────────────────────────────────
_BATCH_KEY_PREFIX = "batch:"
_BATCH_TTL = 86400 # 24h
def _batch_redis_key(batch_id: str) -> str:
return f"{_BATCH_KEY_PREFIX}{batch_id}"
@router.post("/batch-upload")
async def batch_upload(
files: List[UploadFile] = File(...),
material: str = Form("ABS"),
draft_angle: float = Form(2.0),
shrinkage_rate: float = Form(0.5),
parting_precision: float = Form(0.1),
cavity_match: int = Form(95),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""批量上传多个 STP 文件,每个文件创建独立分析任务,用 batch_id 聚合。"""
if not files:
raise HTTPException(400, "请至少上传一个文件")
if len(files) > 20:
raise HTTPException(400, "单次批量上传最多 20 个文件")
process_params = {
"material": material,
"draft_angle": float(draft_angle),
"shrinkage_rate": float(shrinkage_rate),
"parting_precision": float(parting_precision),
"cavity_match": int(cavity_match),
}
batch_id = str(uuid.uuid4())
tasks: List[Dict[str, Any]] = []
storage_service = StorageIntegrationService()
for file in files:
# 文件类型检查
if not file.filename.lower().endswith(('.stp', '.step')):
tasks.append({
"filename": file.filename,
"task_id": None,
"status": "rejected",
"error": "不支持的文件类型",
})
continue
task_id = str(uuid.uuid4())
try:
file_path, file_size, file_meta = await file_handler.save_uploaded_file(file)
stp_file = await storage_service.save_stp_file(
session=db_session,
file_path=file_path,
original_filename=file_meta["safe_original_name"],
user_id=current_user.id,
)
await storage_service.create_processing_task(
db_session, task_id, stp_file.id, parameters=process_params,
)
task_info = create_task_info(
task_id=task_id,
status=ProcessingStatus.PROCESSING,
filename=file.filename,
file_path=str(file_path),
file_size=file_size,
upload_time=str(datetime.now()),
)
task_info["material"] = material
task_info["parameters"] = process_params
task_info["batch_id"] = batch_id
await redis_task_manager.set_task(task_id, task_info)
# 调度处理
if _use_celery:
process_stp_task.delay(task_id, str(file_path), stp_file.id, process_params)
else:
import asyncio
from moldinsight.services.processing_service import processing_service
asyncio.create_task(processing_service.process_file_with_storage(
task_id, str(file_path), stp_file.id, process_params
))
tasks.append({
"filename": file.filename,
"task_id": task_id,
"status": "processing",
"stp_file_id": stp_file.id,
})
logger.info(
f"[BATCH] batch_id={batch_id} task_id={task_id} "
f"file={file.filename} user={current_user.username}"
)
except Exception as exc:
logger.warning(f"[BATCH] 文件 {file.filename} 上传失败: {exc}")
tasks.append({
"filename": file.filename,
"task_id": task_id,
"status": "error",
"error": str(exc),
})
# 将 batch 元数据写入 Redis
batch_meta = {
"batch_id": batch_id,
"user_id": current_user.id,
"created_at": str(datetime.now()),
"task_ids": [t["task_id"] for t in tasks if t.get("task_id")],
"total": len(tasks),
"params": process_params,
}
await redis_task_manager.redis_client.set(
_batch_redis_key(batch_id),
__import__("json").dumps(batch_meta),
ex=_BATCH_TTL,
)
return {
"batch_id": batch_id,
"total": len(tasks),
"accepted": sum(1 for t in tasks if t.get("status") != "rejected"),
"tasks": tasks,
}
@router.get("/batch/{batch_id}")
async def get_batch_status(
batch_id: str,
current_user: User = Depends(get_current_active_user),
):
"""聚合查询批量任务进度"""
import json
raw = await redis_task_manager.redis_client.get(_batch_redis_key(batch_id))
if not raw:
raise HTTPException(404, "批量任务不存在或已过期")
batch_meta = json.loads(raw)
# 权限检查
if batch_meta.get("user_id") and batch_meta["user_id"] != current_user.id:
raise HTTPException(403, "无权访问该批量任务")
task_ids = batch_meta.get("task_ids", [])
task_statuses = []
completed = 0
failed = 0
processing = 0
for tid in task_ids:
task_data = await redis_task_manager.get_task(tid)
if not task_data:
task_statuses.append({"task_id": tid, "status": "unknown"})
continue
status = task_data.get("status", "unknown")
progress = task_data.get("progress", 0)
filename = task_data.get("filename", "")
error = task_data.get("error", "")
html_file = task_data.get("html_file", "")
if status == ProcessingStatus.COMPLETED:
completed += 1
elif status == ProcessingStatus.FAILED:
failed += 1
else:
processing += 1
task_statuses.append({
"task_id": tid,
"status": status,
"progress": progress,
"filename": filename,
"error": error,
"html_file": html_file,
})
total = len(task_ids)
return {
"batch_id": batch_id,
"created_at": batch_meta.get("created_at"),
"total": total,
"completed": completed,
"failed": failed,
"processing": processing,
"progress_percent": round((completed + failed) / max(total, 1) * 100, 1),
"tasks": task_statuses,
}
-43
View File
@@ -34,46 +34,3 @@ async def get_status(task_id: str, db_session: AsyncSession = Depends(get_db_ses
except Exception as e:
logger.error(f"获取任务状态失败: {e}")
raise HTTPException(500, f"获取任务状态失败: {str(e)}")
@router.get("/result/{task_id}")
@router.post("/result/{task_id}")
async def result_page(request: Request, task_id: str, db_session: AsyncSession = Depends(get_db_session)):
"""结果详情页面"""
# 从数据库查询任务详情
result = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(ProcessingTask.task_id == task_id)
)
task_record = result.first()
if not task_record:
raise HTTPException(404, "任务不存在")
task, stp_file = task_record
# 构建任务详情数据
task_data = {
"task_id": task.task_id,
"filename": stp_file.original_filename if stp_file else "",
"file_size": stp_file.file_size if stp_file else 0,
"status": task.status,
"progress": task.progress,
"current_step": task.current_step,
"created_at": task.created_time.isoformat() if task.created_time else "",
"completed_at": task.completed_time.isoformat() if task.completed_time else "",
"error": task.error_message if task.error_message else ""
}
from fastapi.templating import Jinja2Templates
import os
templates_dir = os.path.join(os.getcwd(), "templates")
templates = Jinja2Templates(directory=templates_dir)
return templates.TemplateResponse("result.html", {
"request": request,
"task": task_data,
"pythonocc_available": True,
"version": "3.0.0"
})
@@ -0,0 +1,237 @@
"""
moldinsight/services/cost_estimate_service.py — 规则式成本估算
当 LLM 未启用时作为兜底,基于几何参数 + 材料库 + 模具尺寸
计算材料费 / 加工费,完全不依赖 LLM。
"""
from typing import Dict, Any, Optional
# ─── 模具钢材料单价参考(元/kg,含税) ──────────────────────────────
_MOLD_STEEL_PRICE = {
"铝合金7075": 45,
"P20": 25,
"718H": 35,
"NAK80": 55,
"S136": 70,
"H13": 40,
"default": 30,
}
# ─── 加工复杂度系数 ────────────────────────────────────────────────────
_COMPLEXITY_FACTOR = {
"low": 1.0,
"medium": 1.3,
"high": 1.7,
"very_high": 2.2,
}
# 侧向机构附加费用(元/个)
_SIDE_ACTION_COST = {
"slider": 8000, # 滑块
"lifter": 6000, # 斜顶
"mixed": 7000, # 混合
}
# 型腔加工基础费用(元/型腔)
_CAVITY_MACHINING_BASE = 25000
# 模架基础费用(元)
_BASE_MOLD_FRAME = {
"small": 15000, # 长宽 < 250mm
"medium": 25000, # 长宽 250-400mm
"large": 45000, # 长宽 > 400mm
}
def estimate_cost_by_rules(
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""规则式模具成本估算。
返回结构与 LLM estimate_cost 一致,前端可无缝展示。
"""
ctx = _extract_context(analysis_result, detailed_cavity_json)
# ── 模具材料费 ──────────────────────────────────────────────
mold_weight_kg = _estimate_mold_weight(ctx)
steel_price = _MOLD_STEEL_PRICE.get(ctx["mold_material"], _MOLD_STEEL_PRICE["default"])
material_cost = int(mold_weight_kg * steel_price)
# ── 加工费 ─────────────────────────────────────────────────
cavity_count = ctx["cavity_count"]
complexity_key = ctx["complexity"]
complexity_factor = _COMPLEXITY_FACTOR.get(complexity_key, 1.3)
machining_cost = int(
_CAVITY_MACHINING_BASE * cavity_count * complexity_factor
+ _BASE_MOLD_FRAME[ctx["mold_frame_size"]]
)
# ── 侧向机构附加费 ─────────────────────────────────────────
side_action_extra = 0
side_action_parts = []
slider_count = ctx["slider_count"]
lifter_count = ctx["lifter_count"]
if slider_count > 0:
side_action_extra += slider_count * _SIDE_ACTION_COST["slider"]
side_action_parts.append(f"{slider_count} 个滑块")
if lifter_count > 0:
side_action_extra += lifter_count * _SIDE_ACTION_COST["lifter"]
side_action_parts.append(f"{lifter_count} 个斜顶")
complexity_label = (
f"{complexity_factor:.1f}"
+ (f"(含 {', '.join(side_action_parts)})" if side_action_parts else "")
)
# ── 合计 ───────────────────────────────────────────────────
mold_subtotal = material_cost + machining_cost + side_action_extra
# ── 单件成本 ───────────────────────────────────────────────
part_weight_g = ctx["part_weight_g"]
cycle_time_s = ctx["cycle_time_s"]
# 材料费:塑料粒 ~30 元/kg 均值
material_price_per_kg = 30
part_material_cost = (part_weight_g / 1000) * material_price_per_kg
# 机时分摊:假设机时费 60 元/h
machine_hourly_rate = 60
part_cycle_cost = (cycle_time_s / 3600) * machine_hourly_rate * (1 / max(cavity_count, 1))
# 人工 + 能耗分摊 ~15%
part_overhead = (part_material_cost + part_cycle_cost) * 0.15
cost_per_part = round(part_material_cost + part_cycle_cost + part_overhead, 2)
return {
"mold_cost": {
"material": f"¥{material_cost:,}({ctx['mold_material']},约 {mold_weight_kg:.0f} kg)",
"machining": f"¥{machining_cost:,}(含 CNC/EDM/线切割,{cavity_count} 腔)",
"complexity_factor": complexity_label,
"subtotal": f"¥{mold_subtotal:,}",
},
"part_cost": {
"material": f"¥{part_material_cost:.2f}({ctx['material_name']},约 {part_weight_g:.1f} g)",
"cycle_time": f"{cycle_time_s} s",
"cost_per_part": f"¥{cost_per_part:.2f}",
},
"total_mold_cost": f"¥{mold_subtotal:,}",
"cost_per_part": f"¥{cost_per_part:.2f}",
"confidence": 0.55,
"assumptions": [
"假设模具寿命 50 万模次",
f"模具钢:{ctx['mold_material']}({steel_price} 元/kg)",
f"型腔数:{cavity_count}",
f"机时费:{machine_hourly_rate} 元/h",
"塑料粒均价 30 元/kg",
"人工+能耗分摊 15%",
"规则估算,仅供参考",
],
"source": "rules",
}
def _extract_context(
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""从分析结果提取成本估算所需上下文"""
geometry = analysis_result.get("geometry_data", {})
bbox = geometry.get("bounding_box", {})
dims = bbox.get("dimensions", [0, 0, 0])
volume_mm3 = geometry.get("volume", 0) or 0
schemes = (detailed_cavity_json or {}).get("candidate_schemes", [])
best = schemes[0] if schemes else {}
cavity_data = best.get("cavity_data", {}) if isinstance(best, dict) else {}
mfg_info = cavity_data.get("manufacturing_info", {})
metadata = cavity_data.get("metadata", {})
# 型腔数
cavity_count = (cavity_data.get("mold_cavities", {}) or {}).get("cavity_count", 1)
# 模具材料
mold_material = mfg_info.get("mold_material", "P20")
# 模具尺寸
mold_size = mfg_info.get("estimated_mold_size", {})
length = mold_size.get("length", 300)
width = mold_size.get("width", 300)
max_dim = max(length, width)
if max_dim < 250:
mold_frame_size = "small"
elif max_dim < 400:
mold_frame_size = "medium"
else:
mold_frame_size = "large"
# 复杂度
side_actions = cavity_data.get("side_actions", {}) or {}
summary = side_actions.get("summary", {})
slider_count = summary.get("total_slider_count", 0) or 0
lifter_count = summary.get("total_lifter_count", 0) or 0
total_mechanism = slider_count + lifter_count
if total_mechanism == 0:
complexity = "low"
elif total_mechanism <= 2:
complexity = "medium"
elif total_mechanism <= 4:
complexity = "high"
else:
complexity = "very_high"
# 产品重量
material_name = metadata.get("selected_material", "ABS")
density = 1.05 # ABS 默认密度
part_weight_g = (volume_mm3 / 1000) * density
# 成型周期
cycle_time_raw = mfg_info.get("estimated_cycle_time", "30")
try:
cycle_time_s = int(str(cycle_time_raw).replace("秒", "").strip())
except (ValueError, TypeError):
cycle_time_s = 30
return {
"dims": dims,
"volume_mm3": volume_mm3,
"cavity_count": cavity_count,
"mold_material": mold_material,
"mold_frame_size": mold_frame_size,
"complexity": complexity,
"slider_count": slider_count,
"lifter_count": lifter_count,
"material_name": material_name,
"part_weight_g": part_weight_g,
"cycle_time_s": cycle_time_s,
}
def _estimate_mold_weight(ctx: Dict[str, Any]) -> float:
"""基于模具尺寸估算重量(kg),假设钢材密度 7.85 g/cm³"""
cavity_count = ctx["cavity_count"]
dims = ctx["dims"]
dim_x = max(dims[0] if len(dims) > 0 else 120, 120)
dim_y = max(dims[1] if len(dims) > 1 else 100, 100)
dim_z = max(dims[2] if len(dims) > 2 else 60, 60)
edge_margin = 50
if cavity_count == 1:
length = dim_x + 2 * edge_margin
width = dim_y + 2 * edge_margin
elif cavity_count == 2:
length = 2 * dim_x + 30 + 2 * edge_margin
width = dim_y + 2 * edge_margin
elif cavity_count == 4:
length = 2 * dim_x + 30 + 2 * edge_margin
width = 2 * dim_y + 30 + 2 * edge_margin
else:
length = 4 * dim_x + 90 + 2 * edge_margin
width = 2 * dim_y + 30 + 2 * edge_margin
height = dim_z + 80 # 含冷却系统
# 体积 mm³ → cm³,再乘钢材密度 7.85 g/cm³,再转 kg
# 模架不是实心钢块,取 40% 填充率
volume_cm3 = (length * width * height) / 1000
weight_kg = volume_cm3 * 7.85 * 0.40 / 1000
return max(weight_kg, 50) # 最小 50 kg
@@ -13,9 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from moldinsight.core.stp_parser import STPParser
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
from moldinsight.core.mold_generator import MoldCavityGenerator
from moldinsight.core.aluminum_foam_mold import AluminumFoamMoldGenerator
from moldinsight.core.mold_quality_inspector import AluminumFoamMoldQualityInspector
from moldinsight.core.mesh_generator import MeshGenerator
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
from moldinsight.core.cad_exporter import CADExporter
@@ -38,9 +35,6 @@ class ProcessingService:
def __init__(self):
self.stp_parser = STPParser()
self.geometry_analyzer = GeometryAnalyzer()
self.mold_generator = MoldCavityGenerator(shrinkage_rate=0.005)
self.aluminum_foam_generator = AluminumFoamMoldGenerator(shrinkage_rate=0.015, draft_angle=3.0)
self.mold_quality_inspector = AluminumFoamMoldQualityInspector()
self.mesh_generator = MeshGenerator(quality="medium")
self.html_generator = HTMLGenerator()
self.storage_service = StorageIntegrationService()