Files
mcp-auth/mcp-for-erp/src/server.py
T
2026-08-31 10:24:25 +08:00

354 lines
15 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.
"""
MCP for ERP — 汽车零部件智能报价 ERP 数据服务
使用 MCPServer 框架,5 个工具:物料查询、材质对照、压铸参数、机加工估算、成本核算
HTTP 模式,支持远程调用
"""
import asyncio
import json
from mcp.server import MCPServer
from auth import get_auth
from db import get_pool
def _s(value):
"""查询语句处的 str 归一化:None / 空串 / 纯空白 → 统一为 None,其他原值返回。
只处理 str 类型,不碰 int/float/list/bool。
用法:在每个工具函数开头对所有 str 参数执行 `x = _s(x)`,
确保后续 `if x:` / `if x is not None:` / LIKE 拼接 行为一致。
"""
if isinstance(value, str):
return value if value.strip() else None
return value
# 鉴权:静态 Bearer Token,token → 客户端 映射来自环境变量 MCP_AUTH_TOKENS
_auth, _token_verifier = get_auth(port=8001)
app = MCPServer(
name="mcp-for-erp-auto",
description="汽车零部件智能报价 ERP 数据服务",
version="1.0.0",
auth=_auth,
token_verifier=_token_verifier,
)
# ============================================================
# 工具 1: query_material_master — 查询物料主数据
# ============================================================
@app.tool()
async def query_material_master(
material_code: str = None,
part_category: str = None,
material_grade: str = None,
die_casting_ton: int = None
) -> str:
"""查询 ERP 物料主数据。支持按物料编码精确查询,或按零件类别、材质、吨位等参数组合查询。返回物料列表,包含体积、重量、吨位等关键参数。"""
# 查询语句处 str 归一化:空串/纯空白同 None
material_code = _s(material_code)
part_category = _s(part_category)
material_grade = _s(material_grade)
pool = await get_pool()
query = "SELECT * FROM material_master WHERE 1=1"
params = []
if material_code:
query += f" AND material_code = ${len(params)+1}"
params.append(material_code)
if part_category:
query += f" AND part_category = ${len(params)+1}"
params.append(part_category)
if material_grade:
query += f" AND material_grade = ${len(params)+1}"
params.append(material_grade)
if die_casting_ton:
query += f" AND die_casting_ton = ${len(params)+1}"
params.append(die_casting_ton)
rows = await pool.fetch(query, *params)
materials = [dict(row) for row in rows]
return json.dumps({"total": len(materials), "materials": materials}, ensure_ascii=False, indent=2, default=str)
# ============================================================
# 工具 2: match_material_grade — 材质牌号对照
# ============================================================
@app.tool()
async def match_material_grade(customer_grade: str, standard: str = None) -> str:
"""将客户指定材质牌号自动对照为企业内部等效牌号。支持 GB/ASTM/JIS/EN/ISO 标准体系,返回等效牌号、密度、铝锭参考价。"""
# 查询语句处 str 归一化:空串/纯空白同 None
customer_grade = _s(customer_grade)
standard = _s(standard)
if customer_grade is None:
return json.dumps({"matched": False, "message": "需提供 customer_grade(材质牌号)"}, ensure_ascii=False, indent=2)
pool = await get_pool()
query = """
SELECT * FROM material_grade_mapping
WHERE LOWER(gb_code) LIKE LOWER($1) OR LOWER(astm_code) LIKE LOWER($1)
OR LOWER(jis_code) LIKE LOWER($1) OR LOWER(en_code) LIKE LOWER($1)
OR LOWER(iso_code) LIKE LOWER($1) OR LOWER(internal_code) LIKE LOWER($1)
"""
rows = await pool.fetch(query, f"%{customer_grade}%")
# 牌号带热处理状态后缀(如 A356-T6 / ADC12-F)时,去后缀重试
if not rows:
stripped = customer_grade
for _ in range(2):
if "-" not in stripped:
break
stripped = stripped.rsplit("-", 1)[0]
rows = await pool.fetch(query, f"%{stripped}%")
if rows:
break
if not rows:
return json.dumps({"matched": False, "message": f"未找到材质 {customer_grade}"}, ensure_ascii=False, indent=2)
mappings = [dict(row) for row in rows]
best = mappings[0]
return json.dumps({
"matched": True, "customer_grade": customer_grade, "internal_code": best["internal_code"],
"density": float(best["density"]), "price_per_kg": float(best["price_per_kg"]), "all_mappings": mappings
}, ensure_ascii=False, indent=2, default=str)
# ============================================================
# 工具 3: get_die_casting_params — 获取压铸参数
# ============================================================
@app.tool()
async def get_die_casting_params(material_code: str, volume_cc: float = None) -> str:
"""根据物料编码获取压铸参数。返回压铸机吨位、模次节拍、铝水重量、压铸成本。铝水重量 = 体积 × 密度。"""
# 查询语句处 str 归一化:空串/纯空白同 None
material_code = _s(material_code)
pool = await get_pool()
material = await pool.fetchrow("SELECT * FROM material_master WHERE material_code = $1", material_code)
if not material:
return json.dumps({"success": False, "message": f"未找到物料 {material_code}"}, ensure_ascii=False, indent=2)
vol = volume_cc or float(material["volume_cc"])
density = float(material["density"])
tonnage = material["die_casting_ton"]
aluminum_weight_kg = vol * density / 1000 * 1.05
casting = await pool.fetchrow("SELECT * FROM die_casting_params WHERE tonnage = $1", tonnage)
if not casting:
return json.dumps({"success": False, "message": f"未找到 {tonnage}T 压铸机参数"}, ensure_ascii=False, indent=2)
cycle_hours = float(casting["cycle_time_sec"]) / 3600
casting_cost = cycle_hours * float(casting["machine_rate"]) / casting["mold_cavities"]
return json.dumps({
"success": True, "material_code": material_code, "volume_cc": vol, "density": density,
"aluminum_weight_kg": round(aluminum_weight_kg, 3), "die_casting_ton": tonnage,
"cycle_time_sec": float(casting["cycle_time_sec"]), "machine_rate": float(casting["machine_rate"]),
"mold_cavities": casting["mold_cavities"], "casting_cost_per_part": round(casting_cost, 2)
}, ensure_ascii=False, indent=2)
# ============================================================
# 工具 4: get_machining_estimate — 机加工工时估算
# ============================================================
@app.tool()
async def get_machining_estimate(
material_code: str,
drill_holes: list[dict] = None,
tap_holes: list[dict] = None,
ream_holes: list[dict] = None,
mill_faces: int = 0
) -> str:
"""根据孔清单估算机加工工时。钻孔、攻丝、铰孔、铣面均有固化节拍,逐项累加得到总工时和成本。"""
# 查询语句处 str 归一化:空串/纯空白同 None
material_code = _s(material_code)
pool = await get_pool()
drill_holes = drill_holes or []
tap_holes = tap_holes or []
ream_holes = ream_holes or []
total_time_sec = 0
total_tool_cost = 0
details = []
for hole in drill_holes:
spec = _s(hole.get("spec", "Φ8")) or "Φ8"
count = hole.get("count", 1)
row = await pool.fetchrow("SELECT * FROM machining_cycle WHERE operation_type='drill' AND spec=$1", spec)
if row:
time_sec = float(row["cycle_time_sec"]) * count
tool_cost = float(row["tool_rate"]) * count
total_time_sec += time_sec
total_tool_cost += tool_cost
details.append({"operation": "drill", "spec": spec, "count": count, "total_time_sec": time_sec})
for hole in tap_holes:
spec = _s(hole.get("spec", "M8×1.25")) or "M8×1.25"
count = hole.get("count", 1)
row = await pool.fetchrow("SELECT * FROM machining_cycle WHERE operation_type='tap' AND spec=$1", spec)
if row:
time_sec = float(row["cycle_time_sec"]) * count
tool_cost = float(row["tool_rate"]) * count
total_time_sec += time_sec
total_tool_cost += tool_cost
details.append({"operation": "tap", "spec": spec, "count": count, "total_time_sec": time_sec})
for hole in ream_holes:
spec = _s(hole.get("spec", "Φ8H7")) or "Φ8H7"
count = hole.get("count", 1)
row = await pool.fetchrow("SELECT * FROM machining_cycle WHERE operation_type='ream' AND spec=$1", spec)
if row:
time_sec = float(row["cycle_time_sec"]) * count
tool_cost = float(row["tool_rate"]) * count
total_time_sec += time_sec
total_tool_cost += tool_cost
details.append({"operation": "ream", "spec": spec, "count": count, "total_time_sec": time_sec})
if mill_faces > 0:
row = await pool.fetchrow("SELECT * FROM machining_cycle WHERE operation_type='mill' AND spec='face'")
if row:
time_sec = float(row["cycle_time_sec"]) * mill_faces
tool_cost = float(row["tool_rate"]) * mill_faces
total_time_sec += time_sec
total_tool_cost += tool_cost
details.append({"operation": "mill", "spec": "face", "count": mill_faces, "total_time_sec": time_sec})
return json.dumps({
"success": True, "material_code": material_code, "total_time_sec": round(total_time_sec, 2),
"total_time_hours": round(total_time_sec / 3600, 4), "total_tool_cost": round(total_tool_cost, 2), "details": details
}, ensure_ascii=False, indent=2)
# ============================================================
# 工具 5: calculate_part_cost — 汇总核价
# ============================================================
@app.tool()
async def calculate_part_cost(
material_code: str,
annual_qty: int,
drill_holes: list[dict] = None,
tap_holes: list[dict] = None,
ream_holes: list[dict] = None,
mill_faces: int = 0,
overhead_rate: float = 0.12,
profit_rate: float = 0.15
) -> str:
"""汇总核价:材料成本(铝水重量×单价)+ 压铸成本 + 机加工成本 + 后处理成本 + 管理费 + 利润。返回完整成本明细和最终报价。"""
# 查询语句处 str 归一化:空串/纯空白同 None
material_code = _s(material_code)
pool = await get_pool()
drill_holes = drill_holes or []
tap_holes = tap_holes or []
ream_holes = ream_holes or []
material = await pool.fetchrow("SELECT * FROM material_master WHERE material_code = $1", material_code)
if not material:
return json.dumps({"success": False, "message": f"未找到物料 {material_code}"}, ensure_ascii=False, indent=2)
vol = float(material["volume_cc"])
density = float(material["density"])
tonnage = material["die_casting_ton"]
surface_treatment = material["surface_treatment"]
grade_mapping = await pool.fetchrow(
"SELECT * FROM material_grade_mapping WHERE internal_code = $1 OR jis_code = $1 OR gb_code = $1 OR astm_code = $1 OR en_code = $1 OR iso_code = $1 LIMIT 1",
material["material_grade"]
)
aluminum_price = 18.50
internal_code = None
grade_group = None
price_basis_month = None
if grade_mapping:
internal_code = grade_mapping["internal_code"]
grade_group = grade_mapping["grade_group"]
aluminum_price = float(grade_mapping["price_per_kg"])
try:
price_row = await pool.fetchrow(
"SELECT price_per_kg, price_date FROM material_price_history WHERE internal_code = $1 ORDER BY price_date DESC LIMIT 1",
internal_code
)
if price_row:
aluminum_price = float(price_row["price_per_kg"])
price_basis_month = price_row["price_date"].strftime("%Y-%m")
except Exception:
pass
aluminum_weight_kg = vol * density / 1000 * 1.05
material_cost = aluminum_weight_kg * aluminum_price
casting = await pool.fetchrow("SELECT * FROM die_casting_params WHERE tonnage = $1", tonnage)
casting_cost = (float(casting["cycle_time_sec"]) / 3600) * float(casting["machine_rate"]) / casting["mold_cavities"]
machining_rate_per_hour = 60.0
if grade_group:
try:
rate_row = await pool.fetchrow("SELECT rate_per_hour FROM machining_rate WHERE grade_group = $1", grade_group)
if rate_row:
machining_rate_per_hour = float(rate_row["rate_per_hour"])
except Exception:
pass
total_machining_time_sec = 0
for hole in drill_holes:
spec = _s(hole.get("spec", "Φ8")) or "Φ8"
row = await pool.fetchrow("SELECT cycle_time_sec FROM machining_cycle WHERE operation_type='drill' AND spec=$1", spec)
if row:
total_machining_time_sec += float(row["cycle_time_sec"]) * hole.get("count", 1)
for hole in tap_holes:
spec = _s(hole.get("spec", "M8×1.25")) or "M8×1.25"
row = await pool.fetchrow("SELECT cycle_time_sec FROM machining_cycle WHERE operation_type='tap' AND spec=$1", spec)
if row:
total_machining_time_sec += float(row["cycle_time_sec"]) * hole.get("count", 1)
for hole in ream_holes:
spec = _s(hole.get("spec", "Φ8H7")) or "Φ8H7"
row = await pool.fetchrow("SELECT cycle_time_sec FROM machining_cycle WHERE operation_type='ream' AND spec=$1", spec)
if row:
total_machining_time_sec += float(row["cycle_time_sec"]) * hole.get("count", 1)
if mill_faces > 0:
row = await pool.fetchrow("SELECT cycle_time_sec FROM machining_cycle WHERE operation_type='mill' AND spec='face'")
if row:
total_machining_time_sec += float(row["cycle_time_sec"]) * mill_faces
machining_cost = (total_machining_time_sec / 3600) * machining_rate_per_hour
post_process = await pool.fetchrow("SELECT rate FROM post_process_rate WHERE process_type = $1", surface_treatment)
post_process_cost = float(material["net_weight_kg"]) * float(post_process["rate"]) if post_process else 0
total_cost = material_cost + casting_cost + machining_cost + post_process_cost
overhead = total_cost * overhead_rate
unit_price = (total_cost + overhead) * (1 + profit_rate)
return json.dumps({
"success": True, "material_code": material_code, "part_name": material["part_name"],
"cost_breakdown": {
"material_cost": round(material_cost, 2), "aluminum_weight_kg": round(aluminum_weight_kg, 3),
"aluminum_price_per_kg": aluminum_price, "price_basis_month": price_basis_month,
"internal_code": internal_code, "grade_group": grade_group,
"casting_cost": round(casting_cost, 2),
"die_casting_ton": tonnage, "machining_cost": round(machining_cost, 2),
"machining_hours": round(total_machining_time_sec / 3600, 4),
"machining_rate_per_hour": machining_rate_per_hour,
"post_process_cost": round(post_process_cost, 2), "surface_treatment": surface_treatment
},
"total_cost": round(total_cost, 2), "overhead_rate": overhead_rate, "overhead": round(overhead, 2),
"profit_rate": profit_rate, "unit_price": round(unit_price, 2), "currency": "CNY"
}, ensure_ascii=False, indent=2)
# ============================================================
# 启动 HTTP Server
# ============================================================
if __name__ == "__main__":
asyncio.run(app.run_streamable_http_async(host="0.0.0.0", port=8001, streamable_http_path="/mcp"))