Compare commits
12
Commits
e64e064306
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be6ff06b6 | ||
|
|
1a7184806b | ||
|
|
d71a6f4886 | ||
|
|
db94615272 | ||
|
|
674ff09db1 | ||
|
|
01b3798e02 | ||
|
|
91ac4bb4cf | ||
|
|
4654c17801 | ||
|
|
c74bb0cd6a | ||
|
|
8c62274e45 | ||
|
|
26c982ec74 | ||
|
|
1bcbb23281 |
@@ -0,0 +1,28 @@
|
||||
# ============================================================
|
||||
# MCP Auth 鉴权管理后台 — 环境变量示例
|
||||
# 复制为 .env 后按需修改;.env 不会被 git 跟踪
|
||||
# ============================================================
|
||||
|
||||
# ── 后端(backend/docker-compose.yml / 本地 uvicorn)──
|
||||
# PostgreSQL(mcp_auth 库,先执行 scripts/seed.py 初始化)
|
||||
AUTH_DB_HOST=10.100.154.100
|
||||
AUTH_DB_PORT=5432
|
||||
AUTH_DB_USER=postgres
|
||||
AUTH_DB_PASSWORD=digiwin
|
||||
AUTH_DB_NAME=mcp_auth
|
||||
# 鼎捷云 IAM
|
||||
IAM_BASE_URL=https://iam.digiwincloud.com.cn
|
||||
IAM_APP_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.xxx
|
||||
# IAM token 校验结果 Redis 缓存秒数
|
||||
IAM_CACHE_TTL=30
|
||||
# Redis(缓存 IAM 校验结果)
|
||||
REDIS_HOST=10.100.154.100
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_PASSWORD=digiwin
|
||||
# 外层反向代理路径前缀(直连时留空)
|
||||
ROOT_PATH=/mcp-auth-api
|
||||
|
||||
# ── 前端(frontend/docker-compose.yml)──
|
||||
# 后端 API 地址(nginx 反代目标)
|
||||
BACKEND_URL=https://mcp-auth-admin.digiwincloud.com.cn/mcp-auth-api
|
||||
@@ -37,6 +37,8 @@ node_modules/
|
||||
dist/
|
||||
# Vite 缓存
|
||||
.vite/
|
||||
# TypeScript 增量构建缓存
|
||||
*.tsbuildinfo
|
||||
# npm/pnpm/yarn 锁文件按需保留;如需忽略锁文件取消下行注释
|
||||
# package-lock.json
|
||||
# pnpm-lock.yaml
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# 两个 MCP 服务共用此 Dockerfile,通过构建参数 SERVICE 选择
|
||||
# docker build --build-arg SERVICE=mcp-for-erp -t mcp-for-erp .
|
||||
# docker build --build-arg SERVICE=mcp-for-crm -t mcp-for-crm .
|
||||
FROM python:3.12-slim
|
||||
|
||||
ARG SERVICE
|
||||
WORKDIR /app
|
||||
|
||||
# 依赖单独成层,利用缓存
|
||||
COPY ${SERVICE}/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 拷贝服务源码与 SQL(SQL 供 seed.py 使用,远程库已初始化时无需运行)
|
||||
COPY ${SERVICE}/src /app/src
|
||||
COPY ${SERVICE}/sql /app/sql
|
||||
|
||||
WORKDIR /app/src
|
||||
EXPOSE 8000
|
||||
CMD ["python", "server.py"]
|
||||
@@ -6,7 +6,9 @@ WORKDIR /app
|
||||
|
||||
# 依赖单独成层,利用缓存
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --timeout 120 --retries 5 \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
-r requirements.txt
|
||||
|
||||
# 后端源码
|
||||
COPY app ./app
|
||||
@@ -29,5 +29,9 @@ class Settings:
|
||||
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
||||
REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "digiwin")
|
||||
|
||||
# 反向代理路径前缀(让 Swagger / OpenAPI 在带前缀的代理后正确生成 URL)
|
||||
# 生产经外层 nginx 暴露在 https://<host>/mcp-auth-api/ 下时设为 /mcp-auth-api;直连 8003 时留空
|
||||
ROOT_PATH: str = os.getenv("ROOT_PATH", "")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -32,6 +32,9 @@ app = FastAPI(
|
||||
title="MCP Auth Admin",
|
||||
description="MCP 服务 Bearer Token 动态鉴权管理后台",
|
||||
version="1.0.0",
|
||||
# 反向代理路径前缀:带前缀的外层 nginx(如 /mcp-auth-api)后,
|
||||
# OpenAPI servers 与 Swagger 加载的 openapi.json 会带上该前缀,文档才能正确渲染
|
||||
root_path=settings.ROOT_PATH,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/services", tags=["services"])
|
||||
|
||||
class ServiceCreate(BaseModel):
|
||||
service_name: str # erp / crm / ...
|
||||
service_url: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@@ -22,6 +23,7 @@ def _row_to_dict(row) -> dict:
|
||||
return {
|
||||
"service_id": row["service_id"],
|
||||
"service_name": row["service_name"],
|
||||
"service_url": row["service_url"],
|
||||
"api_key": row["api_key"],
|
||||
"description": row["description"],
|
||||
"status": row["status"],
|
||||
@@ -60,15 +62,23 @@ async def register_service(req: ServiceCreate, admin: dict = Depends(current_adm
|
||||
if existing:
|
||||
raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态")
|
||||
|
||||
# MCP 服务地址唯一性校验
|
||||
url_existing = await pool.fetchval(
|
||||
"SELECT 1 FROM mcp_service WHERE service_url = $1",
|
||||
req.service_url.strip(),
|
||||
)
|
||||
if url_existing:
|
||||
raise HTTPException(400, f"MCP 服务地址 {req.service_url} 已被其他服务使用")
|
||||
|
||||
# 生成 API Key
|
||||
plain = secrets.token_urlsafe(32)
|
||||
api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING service_id, service_name, api_key, description, created_at, created_by""",
|
||||
req.service_name, plain, api_key_hash, req.description, admin.get("username", "admin"),
|
||||
"""INSERT INTO mcp_service (service_name, service_url, api_key, api_key_hash, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING service_id, service_name, service_url, api_key, description, created_at, created_by""",
|
||||
req.service_name, req.service_url.strip(), plain, api_key_hash, req.description, admin.get("username", "admin"),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -17,7 +17,7 @@ router = APIRouter(prefix="/api/tokens", tags=["tokens"])
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
client_id: str
|
||||
service_scope: str
|
||||
service_scope: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp),后端反查服务名入库
|
||||
description: str | None = None
|
||||
expires_at: datetime | None = None # null = 永不过期
|
||||
|
||||
@@ -38,6 +38,7 @@ def _row_to_dict(row) -> dict:
|
||||
"token_plain": row["token_plain"],
|
||||
"client_id": row["client_id"],
|
||||
"service_scope": row["service_scope"],
|
||||
"service_url": row["service_url"],
|
||||
"status": row["status"],
|
||||
"expires_at": row["expires_at"].isoformat() if row["expires_at"] else None,
|
||||
"description": row["description"],
|
||||
@@ -78,21 +79,22 @@ async def list_tokens(
|
||||
async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
||||
pool = await get_pool()
|
||||
|
||||
# 动态校验 service_scope:必须是已注册的 active 服务
|
||||
exists = await pool.fetchval(
|
||||
"SELECT 1 FROM mcp_service WHERE service_name = $1 AND status = 'active'",
|
||||
req.service_scope,
|
||||
# 动态校验 service_scope:传入 MCP 服务地址,反查服务名(必须是已注册的 active 服务)
|
||||
svc_row = await pool.fetchrow(
|
||||
"SELECT service_name, service_url FROM mcp_service WHERE service_url = $1 AND status = 'active'",
|
||||
req.service_scope.strip(),
|
||||
)
|
||||
if not exists:
|
||||
if svc_row is None:
|
||||
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
|
||||
service_name = svc_row["service_name"]
|
||||
|
||||
# client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发
|
||||
existing = await pool.fetchval(
|
||||
"SELECT 1 FROM mcp_token WHERE client_id = $1 AND service_scope = $2 AND status = 'active'",
|
||||
req.client_id, req.service_scope,
|
||||
req.client_id, service_name,
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{req.service_scope}' 的活跃 Token,请先吊销旧 Token")
|
||||
raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{service_name}' 的活跃 Token,请先吊销旧 Token")
|
||||
|
||||
# 生成明文 token:仅此一次返回
|
||||
plain = secrets.token_urlsafe(32)
|
||||
@@ -100,12 +102,12 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
||||
token_prefix = plain[:12] + "…"
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, status,
|
||||
"""INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, service_url, status,
|
||||
expires_at, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)
|
||||
RETURNING token_id, token_prefix, client_id, service_scope, status,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
|
||||
RETURNING token_id, token_prefix, client_id, service_scope, service_url, status,
|
||||
expires_at, description, created_at, created_by""",
|
||||
token_hash, plain, token_prefix, req.client_id, req.service_scope,
|
||||
token_hash, plain, token_prefix, req.client_id, service_name, svc_row["service_url"],
|
||||
req.expires_at, req.description, admin.get("username", "admin"),
|
||||
)
|
||||
|
||||
@@ -113,7 +115,7 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
||||
await pool.execute(
|
||||
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'issued', $2)",
|
||||
row["token_id"],
|
||||
json.dumps({"client_id": req.client_id, "service_scope": req.service_scope}),
|
||||
json.dumps({"client_id": req.client_id, "service_scope": service_name, "service_url": svc_row["service_url"]}),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -122,6 +124,7 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
||||
"token_prefix": row["token_prefix"],
|
||||
"client_id": row["client_id"],
|
||||
"service_scope": row["service_scope"],
|
||||
"service_url": row["service_url"],
|
||||
"message": "请立即保存此 token,之后无法再次查看",
|
||||
}
|
||||
|
||||
@@ -25,4 +25,6 @@ services:
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
- REDIS_DB=${REDIS_DB:-0}
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-digiwin}
|
||||
# 外层反向代理路径前缀(/mcp-auth-api/docs 文档正常渲染所需)
|
||||
- ROOT_PATH=${ROOT_PATH:-/mcp-auth-api}
|
||||
restart: unless-stopped
|
||||
@@ -1,46 +0,0 @@
|
||||
# 汽车零部件智能报价 — MCP 服务编排
|
||||
# 生产环境:docker compose up -d
|
||||
# 默认连生产库 10.100.154.100:5432
|
||||
# 8001 = mcp-for-erp, 8002 = mcp-for-crm
|
||||
# 停止:docker compose down
|
||||
# 日志:docker compose logs -f
|
||||
services:
|
||||
mcp-for-erp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
SERVICE: mcp-for-erp
|
||||
image: mcp-for-erp:latest
|
||||
container_name: mcp-for-erp
|
||||
network_mode: host
|
||||
environment:
|
||||
- ERP_DB_HOST=${ERP_DB_HOST:-10.100.154.100}
|
||||
- ERP_DB_PORT=${ERP_DB_PORT:-5432}
|
||||
- ERP_DB_USER=${ERP_DB_USER:-postgres}
|
||||
- ERP_DB_PASSWORD=${ERP_DB_PASSWORD:-digiwin}
|
||||
- ERP_DB_NAME=${ERP_DB_NAME:-smart_quotation_auto}
|
||||
- MCP_PUBLIC_URL=${MCP_PUBLIC_URL_ERP:-http://10.100.154.100:8001}
|
||||
- MCP_AUTH_API_URL=${MCP_AUTH_API_URL:-http://10.100.154.100:8003}
|
||||
- MCP_AUTH_API_KEY=${ERP_MCP_AUTH_API_KEY:-change-me-erp-key}
|
||||
restart: unless-stopped
|
||||
|
||||
mcp-for-crm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
SERVICE: mcp-for-crm
|
||||
image: mcp-for-crm:latest
|
||||
container_name: mcp-for-crm
|
||||
network_mode: host
|
||||
environment:
|
||||
- CRM_DB_HOST=${CRM_DB_HOST:-10.100.154.100}
|
||||
- CRM_DB_PORT=${CRM_DB_PORT:-5432}
|
||||
- CRM_DB_USER=${CRM_DB_USER:-postgres}
|
||||
- CRM_DB_PASSWORD=${CRM_DB_PASSWORD:-digiwin}
|
||||
- CRM_DB_NAME=${CRM_DB_NAME:-smart_quotation_auto}
|
||||
- MCP_PUBLIC_URL=${MCP_PUBLIC_URL_CRM:-http://10.100.154.100:8002}
|
||||
- MCP_AUTH_API_URL=${MCP_AUTH_API_URL:-http://10.100.154.100:8003}
|
||||
- MCP_AUTH_API_KEY=${CRM_MCP_AUTH_API_KEY:-change-me-crm-key}
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
@@ -7,7 +7,7 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
RUN npm install --registry=https://registry.npmmirror.com
|
||||
|
||||
# 复制源码
|
||||
COPY . .
|
||||
@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
|
||||
# 默认后端 API 地址(运行时可通过 -e BACKEND_URL=... 覆盖)
|
||||
ENV BACKEND_URL=http://10.100.154.100:8003
|
||||
ENV BACKEND_URL=https://ai-workshop.digiwincloud.com.cn/mcp-auth-api
|
||||
|
||||
EXPOSE 8902
|
||||
|
||||
@@ -11,5 +11,5 @@ services:
|
||||
container_name: mcp-auth-frontend
|
||||
network_mode: host
|
||||
environment:
|
||||
- BACKEND_URL=${BACKEND_URL:-http://10.100.154.100:8003}
|
||||
- BACKEND_URL=${BACKEND_URL:-https://mcp-auth-admin.digiwincloud.com.cn/mcp-auth-api}
|
||||
restart: unless-stopped
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MCP Token 管理后台</title>
|
||||
<style>
|
||||
@@ -0,0 +1,116 @@
|
||||
server {
|
||||
listen 8902;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip 压缩
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# ── /console 子路径(外部 nginx 不剥离前缀,需在此处理)──
|
||||
|
||||
# 后端 API 反向代理(/console/api/ → 后端 /api/)
|
||||
location /console/api/ {
|
||||
proxy_pass ${BACKEND_URL}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /console/health {
|
||||
proxy_pass ${BACKEND_URL}/api/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 鼎捷云 IAM 登录服务反向代理(/console/iam-api/ → iam.digiwincloud.com.cn/)
|
||||
location /console/iam-api/ {
|
||||
proxy_pass https://iam.digiwincloud.com.cn/;
|
||||
proxy_set_header Host iam.digiwincloud.com.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# favicon(构建产物在 dist 根目录,/console/favicon.ico 需别名指向,优先于正则图片 location)
|
||||
location = /console/favicon.ico {
|
||||
alias /usr/share/nginx/html/favicon.ico;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# 静态资源长缓存(Vite 构建产物带 hash,/console/assets/ → /assets/)
|
||||
location /console/assets/ {
|
||||
alias /usr/share/nginx/html/assets/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA 路由 fallback:/console 下的非文件请求回退到 index.html
|
||||
location /console/ {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# ── 根路径(直接访问容器时使用)──
|
||||
|
||||
# 后端 API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
proxy_pass ${BACKEND_URL}/api/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 鼎捷云 IAM 登录服务反向代理
|
||||
location /iam-api/ {
|
||||
proxy_pass https://iam.digiwincloud.com.cn/;
|
||||
proxy_set_header Host iam.digiwincloud.com.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 静态资源长缓存
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SVG / 图片缓存
|
||||
location ~* \.(svg|png|jpg|jpeg|gif|ico|webp)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# SPA 路由 fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -2,7 +2,7 @@ import axios from 'axios';
|
||||
import { APP_TOKEN } from '../services/iamAuth';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: `${import.meta.env.BASE_URL}api`,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
@@ -22,10 +22,10 @@ api.interceptors.response.use(
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
const path = window.location.pathname;
|
||||
if (path !== '/login' && !path.startsWith('/sso-login')) {
|
||||
if (path !== `${import.meta.env.BASE_URL}login` && !path.startsWith(`${import.meta.env.BASE_URL}sso-login`)) {
|
||||
sessionStorage.removeItem('userToken');
|
||||
sessionStorage.removeItem('userInfo');
|
||||
window.location.href = '/login';
|
||||
window.location.href = `${import.meta.env.BASE_URL}login`;
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
@@ -6,6 +6,7 @@ export interface TokenRow {
|
||||
token_plain: string | null;
|
||||
client_id: string;
|
||||
service_scope: string;
|
||||
service_url: string | null;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
description: string | null;
|
||||
@@ -108,6 +109,7 @@ export async function getStats() {
|
||||
export interface ServiceRow {
|
||||
service_id: number;
|
||||
service_name: string;
|
||||
service_url: string;
|
||||
api_key: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
@@ -119,6 +121,7 @@ export interface ServiceRow {
|
||||
|
||||
export interface ServiceCreate {
|
||||
service_name: string;
|
||||
service_url: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'antd/dist/reset.css';
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
+35
-3
@@ -96,15 +96,24 @@ export default function Services() {
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await registerService({
|
||||
service_name: values.service_name,
|
||||
service_url: values.service_url,
|
||||
description: values.description,
|
||||
});
|
||||
setCreated(res);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.response?.data?.detail) {
|
||||
message.error(e.response.data.detail);
|
||||
} else if (!e?.errorFields) {
|
||||
message.error('注册失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = (text: string) => {
|
||||
@@ -114,12 +123,11 @@ export default function Services() {
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ServiceRow> = [
|
||||
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 240 },
|
||||
{
|
||||
title: 'API Key',
|
||||
dataIndex: 'api_key',
|
||||
key: 'api_key',
|
||||
width: 300,
|
||||
width: 400,
|
||||
render: (key: string) => (
|
||||
<Space size="small">
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||
@@ -129,7 +137,20 @@ export default function Services() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', key: 'description' },
|
||||
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 150, ellipsis: true },
|
||||
{
|
||||
title: 'MCP服务地址',
|
||||
dataIndex: 'service_url',
|
||||
key: 'service_url',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (u: string) => (
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||
{u}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -149,12 +170,14 @@ export default function Services() {
|
||||
dataIndex: 'last_used_at',
|
||||
key: 'last_used_at',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, row: ServiceRow) =>
|
||||
row.status === 'active' ? (
|
||||
<Space size="small">
|
||||
@@ -197,6 +220,8 @@ export default function Services() {
|
||||
rowKey="service_id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15 }}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1540 }}
|
||||
/>
|
||||
|
||||
{/* 注册服务表单 */}
|
||||
@@ -216,6 +241,13 @@ export default function Services() {
|
||||
>
|
||||
<Input placeholder="如 erp / crm" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="service_url"
|
||||
label="MCP 服务地址"
|
||||
rules={[{ required: true, message: '请输入 MCP 服务地址' }]}
|
||||
>
|
||||
<Input placeholder="如 http://10.100.154.100:8001/mcp" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} placeholder="服务用途说明" />
|
||||
</Form.Item>
|
||||
@@ -38,8 +38,25 @@ export default function Tokens() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [created, setCreated] = useState<CreateResult | null>(null);
|
||||
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||
const [jsonRow, setJsonRow] = useState<TokenRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const buildRegisterJson = (row: TokenRow): string => {
|
||||
const json = {
|
||||
mcpServers: {
|
||||
[row.service_scope]: {
|
||||
type: 'streamable-http',
|
||||
url: row.service_url || '',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${row.token_plain || ''}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return JSON.stringify(json, null, 2);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -110,10 +127,26 @@ export default function Tokens() {
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const onCopy = async (text: string) => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// 非 HTTPS 环境降级方案:临时 textarea + execCommand
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
if (!ok) throw new Error('copy failed');
|
||||
}
|
||||
message.success('已复制');
|
||||
});
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择文本复制');
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
@@ -141,14 +174,26 @@ export default function Tokens() {
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TokenRow> = [
|
||||
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 },
|
||||
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 150, ellipsis: true },
|
||||
{
|
||||
title: '范围',
|
||||
title: '服务标识',
|
||||
dataIndex: 'service_scope',
|
||||
key: 'service_scope',
|
||||
width: 80,
|
||||
width: 180,
|
||||
render: (s: string) => <Tag color="blue">{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'MCP服务地址',
|
||||
dataIndex: 'service_url',
|
||||
key: 'service_url',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (u: string | null) => (
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||
{u || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Token',
|
||||
dataIndex: 'token_plain',
|
||||
@@ -181,19 +226,21 @@ export default function Tokens() {
|
||||
width: 160,
|
||||
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '永不过期'),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', key: 'description' },
|
||||
{ title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '最近调用',
|
||||
dataIndex: 'last_used_at',
|
||||
key: 'last_used_at',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (t: string, row: TokenRow) =>
|
||||
t ? `${dayjs(t).format('MM-DD HH:mm')} (${row.last_used_svc || '-'})` : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
render: (_, row: TokenRow) =>
|
||||
row.status === 'active' ? (
|
||||
<Space size="small">
|
||||
@@ -203,6 +250,9 @@ export default function Tokens() {
|
||||
<Button type="link" size="small" disabled>
|
||||
删除
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => setJsonRow(row)}>
|
||||
注册JSON
|
||||
</Button>
|
||||
</Space>
|
||||
) : row.status === 'revoked' ? (
|
||||
<Space size="small">
|
||||
@@ -236,6 +286,8 @@ export default function Tokens() {
|
||||
rowKey="token_id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15 }}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1810 }}
|
||||
/>
|
||||
|
||||
{/* 签发表单 */}
|
||||
@@ -263,7 +315,7 @@ export default function Tokens() {
|
||||
<Select
|
||||
placeholder="选择 MCP 服务"
|
||||
options={services.map((s) => ({
|
||||
value: s.service_name,
|
||||
value: s.service_url,
|
||||
label: s.service_name.toUpperCase(),
|
||||
}))}
|
||||
/>
|
||||
@@ -297,6 +349,34 @@ export default function Tokens() {
|
||||
{created?.message}
|
||||
</Typography.Paragraph>
|
||||
</Modal>
|
||||
|
||||
{/* 注册 JSON 查看结果 */}
|
||||
<Modal
|
||||
title={`注册 JSON — ${jsonRow?.client_id || ''}`}
|
||||
open={jsonRow !== null}
|
||||
onCancel={() => setJsonRow(null)}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
if (jsonRow) onCopy(buildRegisterJson(jsonRow));
|
||||
setJsonRow(null);
|
||||
}}
|
||||
>
|
||||
复制并关闭
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={12}
|
||||
value={jsonRow ? buildRegisterJson(jsonRow) : ''}
|
||||
readOnly
|
||||
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { JSEncrypt } from 'jsencrypt';
|
||||
*/
|
||||
|
||||
// 代理路径(vite.config.ts 中 /iam-api → https://iam.digiwincloud.com.cn)
|
||||
const IAM_API_BASE = '/iam-api/api/iam/v2';
|
||||
const IAM_API_BASE = `${import.meta.env.BASE_URL}iam-api/api/iam/v2`;
|
||||
const IAM_IDENTITY_BASE = `${IAM_API_BASE}/identity`;
|
||||
|
||||
// 应用 apptoken(digi-middleware-auth-app)
|
||||
@@ -3,15 +3,21 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/console/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
// 生产部署在 /console 子路径下,dev 代理路径也需带前缀
|
||||
'/console/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/console/, ''),
|
||||
},
|
||||
// 鼎捷云 IAM 登录服务代理(避免 CORS / 网络问题)
|
||||
'/iam-api': {
|
||||
'/console/iam-api': {
|
||||
target: 'https://iam.digiwincloud.com.cn',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/iam-api/, ''),
|
||||
rewrite: (p) => p.replace(/^\/console\/iam-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1,63 +0,0 @@
|
||||
server {
|
||||
listen 8902;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip 压缩
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# 后端 API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL}/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 禁止浏览器缓存 API 响应
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
proxy_pass ${BACKEND_URL}/api/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 鼎捷云 IAM 登录服务反向代理(与 vite.config.ts 中 /iam-api 代理一致)
|
||||
location /iam-api/ {
|
||||
proxy_pass https://iam.digiwincloud.com.cn/;
|
||||
proxy_set_header Host iam.digiwincloud.com.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
# 禁止浏览器缓存 API 响应
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
}
|
||||
|
||||
# SPA 路由 fallback:所有非文件请求回退到 index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源长缓存(Vite 构建产物带 hash)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SVG / 图片缓存
|
||||
location ~* \.(svg|png|jpg|jpeg|gif|ico|webp)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/index.ts","./src/pages/login/index.tsx","./src/pages/ssologin/index.tsx","./src/pages/services/index.tsx","./src/pages/stats/index.tsx","./src/pages/tokens/index.tsx","./src/services/iamauth.ts"],"version":"5.9.3"}
|
||||
@@ -1,555 +0,0 @@
|
||||
# MCP for CRM — 工具说明文档
|
||||
|
||||
> 服务对象:汽车零部件(小型阀体外壳)智能报价智能体
|
||||
> 服务定位:CRM 侧客户/询价/报价/商机数据服务(业务写入 + 只读回读 + 受限清理)
|
||||
> 版本:v1.1(2026-08-09)
|
||||
|
||||
---
|
||||
|
||||
## 1 服务概览
|
||||
|
||||
| 项 | 值 |
|
||||
|----|----|
|
||||
| 服务名 | `mcp-for-crm-auto` |
|
||||
| 框架 | MCPServer(mcp 2.0.0 内置高级框架),`@app.tool()` 装饰器注册 |
|
||||
| 传输协议 | Streamable HTTP(JSON-RPC 2.0) |
|
||||
| 远程端点 | `https://crm-data.dongsk.top/mcp` |
|
||||
| 本地端点 | `http://0.0.0.0:8002/mcp`(`cd src && python3 server.py`) |
|
||||
| 数据库 | PostgreSQL 15+,库 `smart_quotation_auto`(仅服务层内部经 `db.py` 连接池访问) |
|
||||
| 工具数 | **10 个** = 6 个业务工具 + 4 个验证/管理工具 |
|
||||
|
||||
### 工具一览
|
||||
|
||||
| # | 工具 | 功能 | 类型 | 报价环节 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | `get_customer_info` | 查询客户信用/折扣/账期 | 只读 | Step 2 客户查询 |
|
||||
| 2 | `create_inquiry` | 创建询价单 | 写入 | Step 7 存档 |
|
||||
| 3 | `save_quotation` | 保存报价单(自动版本+1) | 写入(事务) | Step 7 存档 |
|
||||
| 4 | `get_quotation_history` | 历史报价/版本链查询 | 只读 | Step 7 前置检查 |
|
||||
| 5 | `update_opportunity` | 创建/更新商机 | 写入 | Step 7 存档 |
|
||||
| 6 | `get_inquiry_info` | 询价单整行回读 | 只读 | 存档后验证 |
|
||||
| 7 | `get_opportunity_info` | 商机回读 | 只读 | 存档后验证 |
|
||||
| 8 | `get_record_counts` | 三表行数统计 | 只读 | 数据卫生检查 |
|
||||
| 9 | `purge_test_records` | 受限测试数据清理 | 写入(带防护) | 回归测试清理 |
|
||||
| 10 | `save_quotation_mock` | 报价写入 CRM(Mock 模拟) | 写入(独立 mock 表) | 模拟报价写入 CRM |
|
||||
|
||||
### 单号与状态约定
|
||||
|
||||
| 实体 | 单号规则 | 状态机 |
|
||||
|------|----------|--------|
|
||||
| 询价单 | `INQ-2026-NNNN`(自增) | `pending` → `quoted`(首次报价自动推进) |
|
||||
| 报价单 | `QUO-2026-NNNN`(自增) | `draft` → `superseded`(被新版替代);`won` / `lost` 为终态,受保护 |
|
||||
| 商机 | `OPP-2026-NNNN`(自增) | `lead` → `quoted` → `won` / `lost` |
|
||||
|
||||
---
|
||||
|
||||
## 2 调用约定
|
||||
|
||||
### 2.1 客户端注册(mcpServers)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"crm": { "url": "https://crm-data.dongsk.top/mcp" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 兜底命令行客户端(纯标准库)
|
||||
|
||||
```bash
|
||||
python3 scripts/mcp_http_client.py crm <工具名> '<JSON参数>'
|
||||
# 示例
|
||||
python3 scripts/mcp_http_client.py crm get_customer_info '{"customer_code":"OEM-2024-003"}'
|
||||
```
|
||||
|
||||
### 2.3 返回通用约定
|
||||
|
||||
- 所有工具返回 **JSON 字符串**(`ensure_ascii=False`,中文不转义)。
|
||||
- 查询类:`{"total": N, "<集合名>": [...]}`;写入/操作类:`{"success": true|false, ...}`。
|
||||
- 失败统一形态:`{"success": false, "message": "<原因>"}`。
|
||||
- 金额字段(decimal 列)在 JSON 中为字符串,如 `"180000.00"`;日期 `YYYY-MM-DD`。
|
||||
- 日期入参格式必须为 `YYYY-MM-DD`,否则返回格式错误。
|
||||
|
||||
---
|
||||
|
||||
## 3 业务工具详解(6 个)
|
||||
|
||||
### 3.1 get_customer_info — 查询客户信息
|
||||
|
||||
**功能**:按客户编码精确查询,或按名称、OEM 层级模糊搜索。返回信用等级、折扣率、账期、历史订单统计,用于报价前的客户校验与商务条款确定。
|
||||
|
||||
**入参**(全部可选;全部不传 = 返回全表):
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `customer_code` | string | 否 | — | 客户编码,精确匹配,如 `OEM-2024-003` |
|
||||
| `name` | string | 否 | — | 客户名称,`%模糊%` 匹配 |
|
||||
| `oem_tier` | string | 否 | — | 层级精确匹配:`OEM` / `Tier1` / `Tier2` |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | int | 命中条数 |
|
||||
| `customers[].customer_code` | string | 客户编码 |
|
||||
| `customers[].name` | string | 客户名称 |
|
||||
| `customers[].oem_tier` | string | OEM / Tier1 / Tier2 |
|
||||
| `customers[].credit_level` | string | 信用等级 A/B/C/D 或「无评级」 |
|
||||
| `customers[].discount_rate` | decimal | 折扣率(字符串,如 `"0.040"` = 4%) |
|
||||
| `customers[].payment_days` | int | 账期天数(月结 N 天) |
|
||||
| `customers[].contact_person` / `phone` / `email` | string | 联系人信息 |
|
||||
| `customers[].industry` / `region` | string | 行业 / 区域 |
|
||||
| `customers[].total_orders` | int | 历史订单数 |
|
||||
| `customers[].total_amount` | decimal | 历史订单总额 |
|
||||
| `customers[].notes` | string | 备注 |
|
||||
|
||||
**返回示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"customers": [
|
||||
{
|
||||
"customer_code": "OEM-2024-003",
|
||||
"name": "某新能源主机厂",
|
||||
"oem_tier": "OEM",
|
||||
"credit_level": "A",
|
||||
"discount_rate": "0.040",
|
||||
"payment_days": 60,
|
||||
"contact_person": "刘工",
|
||||
"phone": "0551-2222-0005",
|
||||
"email": "liu@oem-nev.example.com",
|
||||
"industry": "汽车",
|
||||
"region": "合肥",
|
||||
"total_orders": 22,
|
||||
"total_amount": "9800000.00",
|
||||
"notes": "新能源三电系统配套,增长快",
|
||||
"created_at": "2026-08-07 17:31:56.244396"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 create_inquiry — 创建询价记录
|
||||
|
||||
**功能**:客户提交询价后建档。校验客户存在性,自动生成 `INQ-2026-NNNN` 单号,初始状态 `pending`。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `customer_code` | string | **是** | — | 客户编码(不存在则失败) |
|
||||
| `drawing_number` | string | 否 | null | 图纸号 |
|
||||
| `part_number` | string | 否 | null | 零件号 |
|
||||
| `annual_volume` | int | 否 | null | 年用量(件/年) |
|
||||
| `target_price` | float | 否 | null | 客户目标价 ¥ |
|
||||
| `notes` | string | 否 | null | 备注 |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success` | bool | 成功标记 |
|
||||
| `inquiry_id` | string | 生成的询价单号 |
|
||||
| `customer_name` | string | 客户名称 |
|
||||
| `status` | string | 固定 `pending` |
|
||||
| `message` | string | 提示信息 |
|
||||
|
||||
**失败返回**:`{"success": false, "message": "未找到客户 XXX"}`
|
||||
|
||||
**返回示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"inquiry_id": "INQ-2026-0028",
|
||||
"customer_name": "某新能源主机厂",
|
||||
"status": "pending",
|
||||
"message": "询价记录 INQ-2026-0028 已创建"
|
||||
}
|
||||
```
|
||||
|
||||
**业务规则**:同一询价单可多次创建(每次生成新单号);「同一询价单多次报价」通过 3.3 `save_quotation` 的版本机制实现,不重复建档。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 save_quotation — 保存报价单(多版本核心)
|
||||
|
||||
**功能**:报价存档核心工具。同一询价单**不限报价次数**,版本号自动 MAX+1;旧版非终态自动置 `superseded`;首报自动推询价单 `pending → quoted`;`won` 终态保护(仍存档但带 warning)。事务内完成:作废旧版 → 推询价状态 → 写报价主表 → 写成本明细表。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `inquiry_id` | string | **是** | — | 询价单号(不存在则失败并提示先建档) |
|
||||
| `customer_code` | string | **是** | — | 客户编码(不存在则失败) |
|
||||
| `mold_cost` | float | **是** | — | 模具费 ¥(一次性) |
|
||||
| `unit_price` | float | **是** | — | 单价 ¥ |
|
||||
| `annual_volume` | int | **是** | — | 年用量 |
|
||||
| `material_cost` | float | **是** | — | 材料成本 ¥/件 |
|
||||
| `casting_cost` | float | **是** | — | 压铸成本 ¥/件 |
|
||||
| `machining_cost` | float | **是** | — | 机加工成本 ¥/件 |
|
||||
| `post_process_cost` | float | **是** | — | 后处理成本 ¥/件 |
|
||||
| `overhead_rate` | float | 否 | 0.12 | 管理费率 |
|
||||
| `profit_rate` | float | 否 | 0.15 | 利润率 |
|
||||
| `tax_rate` | float | 否 | 0.13 | 税率 |
|
||||
| `payment_terms` | string | 否 | `月结30天` | 付款条款 |
|
||||
| `delivery_terms` | string | 否 | `含税含运` | 交付条款 |
|
||||
| `valid_days` | int | 否 | 30 | 报价有效天数(自当日起) |
|
||||
| `remarks` | string | 否 | null | 备注 |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success` | bool | 成功标记 |
|
||||
| `quotation_id` | string | 生成的报价单号 |
|
||||
| `status` | string | 固定 `draft`(草稿,待人工审核后发出) |
|
||||
| `version` | int | 本次版本号(自动递增) |
|
||||
| `previous_quotation_id` | string\|null | 上一版报价单号(首报为 null) |
|
||||
| `price_change_pct` | float\|null | 较上版价差百分比(首报为 null) |
|
||||
| `summary.mold_cost` / `unit_price` / `annual_volume` | float/int | 回显 |
|
||||
| `summary.total_annual` | float | 年总额 = 单价×年用量 |
|
||||
| `summary.tax_rate` | string | 税率百分比字符串,如 `"13%"` |
|
||||
| `summary.tax_amount` | float | 税额 |
|
||||
| `summary.total_amount` | float | 含税总额 |
|
||||
| `summary.currency` | string | 固定 `CNY` |
|
||||
| `valid_until` | string | 有效期至 `YYYY-MM-DD` |
|
||||
| `warning` | string(可选) | 存在中标旧版时的提示 |
|
||||
| `message` | string | 提示信息 |
|
||||
|
||||
**返回示例**(第 2 版议价):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"quotation_id": "QUO-2026-0029",
|
||||
"status": "draft",
|
||||
"version": 2,
|
||||
"previous_quotation_id": "QUO-2026-0028",
|
||||
"price_change_pct": -3.0,
|
||||
"summary": {
|
||||
"mold_cost": 180000,
|
||||
"unit_price": 42.7,
|
||||
"annual_volume": 80000,
|
||||
"total_annual": 3416000.0,
|
||||
"tax_rate": "13%",
|
||||
"tax_amount": 444080.0,
|
||||
"total_amount": 3860080.0,
|
||||
"currency": "CNY"
|
||||
},
|
||||
"valid_until": "2026-09-08",
|
||||
"message": "报价单 QUO-2026-0029(第 2 版)已保存为草稿,待人工审核后发出"
|
||||
}
|
||||
```
|
||||
|
||||
**业务规则**:
|
||||
- 版本链查询用 3.4;`won`/`lost` 终态永不被 superseded 覆盖。
|
||||
- 并发安全由数据库 `(inquiry_id, version)` 唯一约束兜底。
|
||||
- 草稿态报价单在 HTML 上带「草稿·待人工审核」水印,人工审核后才可发出。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 get_quotation_history — 查询历史报价
|
||||
|
||||
**功能**:历史报价与版本链查询,用于再报价前置检查(有没有报过、上一版多少钱)与价格一致性校验。同一询价单按版本号降序返回,并逐行标记 `is_latest`。
|
||||
|
||||
**入参**(全部可选):
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `customer_code` | string | 否 | — | 按客户筛选 |
|
||||
| `inquiry_id` | string | 否 | — | 按询价单筛选 |
|
||||
| `start_date` | string | 否 | — | 报价日期起,`YYYY-MM-DD` |
|
||||
| `end_date` | string | 否 | — | 报价日期止,`YYYY-MM-DD` |
|
||||
| `status` | string | 否 | — | 状态筛选:draft/superseded/won/lost |
|
||||
| `latest_only` | bool | 否 | false | true 时每个询价单仅返回最新一版 |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | int | 命中条数 |
|
||||
| `quotations[]` | array | 报价行(quotation 全字段 + `customer_name`) |
|
||||
| `quotations[].version` | int | 版本号 |
|
||||
| `quotations[].status` | string | 状态 |
|
||||
| `quotations[].is_latest` | bool | 是否为该询价单最新版本(全表口径,不受筛选影响) |
|
||||
| `quotations[].cost_detail` | object | 成本明细(四项成本 + 费率 + total_cost + unit_price) |
|
||||
|
||||
**返回示例**(INQ-2026-0019 三版议价链,latest_only=true):
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"quotations": [
|
||||
{
|
||||
"quotation_id": "QUO-2026-0018",
|
||||
"inquiry_id": "INQ-2026-0019",
|
||||
"customer_code": "OEM-2024-002",
|
||||
"quotation_date": "2026-08-07",
|
||||
"valid_until": "2026-09-06",
|
||||
"version": 3,
|
||||
"status": "draft",
|
||||
"mold_cost": "180000.00",
|
||||
"unit_price": "41.50",
|
||||
"annual_volume": 80000,
|
||||
"customer_name": "某商用车主机厂",
|
||||
"is_latest": true,
|
||||
"cost_detail": { "...": "四项成本与费率" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:日期参数格式错误(非 `YYYY-MM-DD`)会抛出解析异常,调用方应保证格式。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 update_opportunity — 创建/更新商机
|
||||
|
||||
**功能**:双模式。传 `opportunity_id` = 更新已有商机的阶段/金额/概率等字段(仅更新非 null 字段);不传 `opportunity_id` = 按 `customer_code + title` 新建商机(默认 stage=lead、probability=20、expected_amount=0,source=智能报价自动生成)。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `opportunity_id` | string | 否 | — | 商机号(传入则走更新模式) |
|
||||
| `customer_code` | string | 新建时必填 | — | 客户编码 |
|
||||
| `title` | string | 新建时必填 | — | 商机标题 |
|
||||
| `stage` | string | 否 | 新建时 `lead` | 阶段:lead/quoted/won/lost |
|
||||
| `expected_amount` | float | 否 | 新建时 0 | 预期金额 ¥ |
|
||||
| `probability` | int | 否 | 新建时 20 | 成交概率 % |
|
||||
| `oem_program` | string | 否 | — | OEM 项目/平台 |
|
||||
| `expected_close_date` | string | 否 | — | 预期关单日期 `YYYY-MM-DD` |
|
||||
| `notes` | string | 否 | — | 备注(保留参数) |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success` | bool | 成功标记 |
|
||||
| `opportunity_id` | string | 商机号(更新时为传入值,新建时为生成值) |
|
||||
| `message` | string | 「已更新」或「已创建」 |
|
||||
|
||||
**失败返回**:
|
||||
- `{"success": false, "message": "expected_close_date 格式错误:...,应为 YYYY-MM-DD"}`
|
||||
- `{"success": false, "message": "未找到商机 OPP-2026-XXXX"}`
|
||||
- `{"success": false, "message": "新建商机需要提供 customer_code 和 title"}`
|
||||
- `{"success": false, "message": "未找到客户 XXX"}`
|
||||
|
||||
**返回示例**(新建):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"opportunity_id": "OPP-2026-0019",
|
||||
"message": "商机 OPP-2026-0019 已创建"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 save_quotation_mock — 报价写入 CRM(Mock 模拟)
|
||||
|
||||
**用途**:模拟「报价写入 CRM」动作。写入独立 mock 表 `quotation_mock`,不校验客户/询价单/商机,与其他工具无任何关联,适合演示与联调场景单独调用。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `part_name` | string | ✅ | — | 零件名称 |
|
||||
| `unit_price` | number | ✅ | — | 报价单价 |
|
||||
| `customer_name` | string | — | null | 客户名称(自由文本,不做存在性校验) |
|
||||
| `part_number` | string | — | null | 零件号 |
|
||||
| `material_grade` | string | — | null | 材质牌号 |
|
||||
| `mold_cost` | number | — | 0.0 | 模具费 |
|
||||
| `annual_volume` | integer | — | null | 年用量(提供时自动计算 `total_annual = unit_price × annual_volume`) |
|
||||
| `currency` | string | — | CNY | 币种 |
|
||||
| `remarks` | string | — | null | 备注 |
|
||||
|
||||
**返回示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"quotation_id": "QUO-MOCK-0001",
|
||||
"status": "draft",
|
||||
"summary": {
|
||||
"part_name": "变速箱阀体外壳", "unit_price": 41.66, "mold_cost": 180000.0,
|
||||
"annual_volume": 300000, "total_annual": 12498000.0, "currency": "CNY"
|
||||
},
|
||||
"message": "报价单 QUO-MOCK-0001 已写入 CRM mock 表(quotation_mock)"
|
||||
}
|
||||
```
|
||||
|
||||
**关键说明**:
|
||||
- 单号 `QUO-MOCK-####` 自增,独立于正式 `QUO-2026-####` 序列。
|
||||
- `quotation_mock` 表无外键,写入不影响正式三表(inquiry/quotation/opportunity),`get_record_counts` 也不统计该表。
|
||||
- 无版本管理、无状态流转:每次调用新增一行,状态固定 `draft`。
|
||||
|
||||
## 4 验证/管理工具详解(4 个)
|
||||
|
||||
> 用途:**MCP 读写分离交叉验证**——写工具的返回值与只读回读工具交叉核对;测试数据清理经受限工具完成。
|
||||
> 原则:验证/清理环节禁止直连数据库;`db.py` 仅为服务层内部连接池。
|
||||
|
||||
### 4.1 get_inquiry_info — 询价单回读(只读)
|
||||
|
||||
**功能**:按询价单号回读询价单整行,用于存档后验证(状态、图号、零件号、年用量、目标价、创建时间)。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `inquiry_id` | string | **是** | 询价单号 |
|
||||
|
||||
**返回**:命中 `{"success": true, "inquiry": {<整行字段>}}`;未命中 `{"success": false, "message": "未找到询价单 XXX"}`。
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"inquiry": {
|
||||
"inquiry_id": "INQ-2026-0025",
|
||||
"customer_code": "T1-2025-002",
|
||||
"inquiry_date": "2026-08-08",
|
||||
"drawing_number": "DWG-VHB-MN-Rev1",
|
||||
"part_number": "P-VHB-009",
|
||||
"annual_volume": 5000,
|
||||
"target_price": "30.00",
|
||||
"status": "quoted",
|
||||
"notes": "TC-05生产报价:微型泵阀体壳 ADC12 96.3cc 160T一模两腔...",
|
||||
"created_at": "2026-08-08 13:34:11.945841"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 get_opportunity_info — 商机回读(只读)
|
||||
|
||||
**功能**:按商机号或客户编码回读商机,用于存档后验证(stage/probability/expected_amount/更新时间)。两参数二选一,`opportunity_id` 优先。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `opportunity_id` | string | 二选一 | 商机号(精确单条) |
|
||||
| `customer_code` | string | 二选一 | 客户编码(该客户全部商机,按创建时间倒序) |
|
||||
|
||||
**返回**:`{"total": N, "opportunities": [<整行字段>]}`;两参数都未提供时 `{"success": false, "message": "需提供 opportunity_id 或 customer_code"}`。
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"opportunities": [
|
||||
{
|
||||
"opportunity_id": "OPP-2026-0018",
|
||||
"customer_code": "T1-2025-002",
|
||||
"title": "微型泵阀体壳 ADC12(P-VHB-009)首单",
|
||||
"stage": "quoted",
|
||||
"expected_amount": "40300.00",
|
||||
"probability": 50,
|
||||
"oem_program": "大陆制动系统配套",
|
||||
"expected_close_date": "2026-09-30",
|
||||
"source": "智能报价自动生成",
|
||||
"created_by": "AI智能报价",
|
||||
"updated_at": "2026-08-08"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 get_record_counts — 三表行数统计(只读)
|
||||
|
||||
**功能**:返回 inquiry/quotation/opportunity 三表行数,用于数据卫生检查(如回归前后行数一致性核对)。
|
||||
|
||||
**入参**:无。
|
||||
|
||||
**返回示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"counts": { "inquiry": 24, "quotation": 27, "opportunity": 18 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 purge_test_records — 受限测试数据清理(管理)
|
||||
|
||||
**功能**:清理 TC-03 回归测试写入的数据。**带硬性防护**:仅当报价单 `remarks`、询价单 `notes` 含「TC-03回归测试」,或商机 `title` 以「TC-03」开头时才允许删除;其余一律进入 `rejected` 列表,防止误删演示/生产数据。级联删除子表(quotation_cost_detail / inquiry_item)。
|
||||
|
||||
**入参**(至少传一组;均为 ID 数组):
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `quotation_ids` | array[string] | 否 | 待清理报价单号列表 |
|
||||
| `inquiry_ids` | array[string] | 否 | 待清理询价单号列表 |
|
||||
| `opportunity_ids` | array[string] | 否 | 待清理商机号列表 |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success` | bool | 固定 true(单条拒绝不影响整体) |
|
||||
| `deleted.quotations` / `deleted.inquiries` / `deleted.opportunities` | int | 实际删除条数 |
|
||||
| `deleted.rejected` | array[string] | 被防护拒绝的 ID 列表(非测试标记或不存在) |
|
||||
|
||||
**示例一:正常清理(回归测试自动调用)**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"deleted": { "quotations": 3, "inquiries": 3, "opportunities": 1, "rejected": [] }
|
||||
}
|
||||
```
|
||||
|
||||
**示例二:防护生效(对生产数据调用)**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"deleted": {
|
||||
"quotations": 0, "inquiries": 0, "opportunities": 0,
|
||||
"rejected": ["QUO-2026-0027", "INQ-2026-0025", "OPP-2026-0018"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5 附录
|
||||
|
||||
### 5.1 依赖数据表
|
||||
|
||||
| 表 | 用途 |
|
||||
|----|------|
|
||||
| `customer` | 客户主数据(信用/折扣/账期/历史统计) |
|
||||
| `inquiry` | 询价单(pending→quoted) |
|
||||
| `quotation` | 报价单主表(版本链/状态机/金额) |
|
||||
| `quotation_cost_detail` | 报价成本明细(四项成本+费率) |
|
||||
| `opportunity` | 商机(阶段/金额/概率/关单日期) |
|
||||
|
||||
### 5.2 标准存档链路(智能体 Step 7)
|
||||
|
||||
```
|
||||
get_quotation_history(inquiry_id) ← 前置:查有无历史版本
|
||||
├─ 无 → create_inquiry(...) ← 建询价单
|
||||
└─ 有 → 复用原 inquiry_id
|
||||
save_quotation(...) ← 存报价(版本自动+1,事务)
|
||||
update_opportunity(...) ← 商机同步最新版年总额(stage=quoted)
|
||||
get_inquiry_info / get_opportunity_info ← 可选:存档后交叉验证
|
||||
```
|
||||
|
||||
### 5.3 与 ERP 服务的协作
|
||||
|
||||
核价计算(材质对照/压铸参数/机加工工时/汇总核价)由 `mcp-for-erp` 承担,见《MCP for ERP — 工具说明文档》;CRM 服务不做核价计算,只做客户与商务数据管理。
|
||||
@@ -1,49 +0,0 @@
|
||||
# MCP for CRM — 汽车零部件智能报价 CRM 数据服务
|
||||
|
||||
## 概述
|
||||
|
||||
为汽车零部件(小型阀体外壳)智能报价智能体提供 CRM 数据访问能力,通过 MCP 协议暴露 10 个工具(6 个业务 + 4 个验证/管理)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: MCPServer(mcp 2.0.0 内置高级框架)
|
||||
- **语言**: Python 3.11+
|
||||
- **数据库**: PostgreSQL 15+
|
||||
- **传输协议**: Streamable HTTP(远程调用)
|
||||
|
||||
## 工具清单
|
||||
|
||||
| 工具名 | 功能 |
|
||||
|---|---|
|
||||
| `get_customer_info` | 查询客户信息(OEM/Tier、信用、折扣率) |
|
||||
| `create_inquiry` | 创建询价记录(关联图纸号、零件号、年用量) |
|
||||
| `save_quotation` | 保存报价单(含模具费、单价、成本明细、版本管理) |
|
||||
| `get_quotation_history` | 查询历史报价(按客户/零件号/日期) |
|
||||
| `update_opportunity` | 商机状态管理(lead→quoted→won/lost) |
|
||||
| `save_quotation_mock` | 报价写入 CRM(Mock 模拟,独立 mock 表,与其他工具无关联) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
cd src
|
||||
python seed.py # 初始化数据库(可选,数据已在共享数据库中)
|
||||
python server.py # 启动 MCP Server(端口 8002)
|
||||
```
|
||||
|
||||
## 服务地址
|
||||
|
||||
- **HTTP 端点**: `http://0.0.0.0:8002/mcp`
|
||||
- **传输协议**: Streamable HTTP
|
||||
|
||||
## 数据库配置
|
||||
|
||||
通过环境变量配置:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `CRM_DB_HOST` | 10.100.154.100 | PostgreSQL 主机 |
|
||||
| `CRM_DB_PORT` | 5432 | 端口 |
|
||||
| `CRM_DB_USER` | postgres | 用户名 |
|
||||
| `CRM_DB_PASSWORD` | digiwin | 密码 |
|
||||
| `CRM_DB_NAME` | smart_quotation_auto | 数据库名 |
|
||||
@@ -1,4 +0,0 @@
|
||||
mcp>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
httpx>=0.27.0
|
||||
@@ -1,271 +0,0 @@
|
||||
-- ============================================================
|
||||
-- MCP for CRM — 汽车零部件智能报价 CRM 数据服务
|
||||
-- 数据库:smart_quotation_auto
|
||||
-- v3: 幂等可重跑(唯一约束+去重);扩充至 20 客户 + 历史询价/报价/商机全谱系
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 客户
|
||||
CREATE TABLE IF NOT EXISTS customer (
|
||||
customer_code VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
oem_tier VARCHAR(20) NOT NULL,
|
||||
credit_level VARCHAR(5) DEFAULT 'B',
|
||||
discount_rate DECIMAL(4,3) DEFAULT 0,
|
||||
payment_days INTEGER DEFAULT 30,
|
||||
contact_person VARCHAR(50),
|
||||
phone VARCHAR(30),
|
||||
email VARCHAR(100),
|
||||
industry VARCHAR(50),
|
||||
region VARCHAR(50),
|
||||
total_orders INTEGER DEFAULT 0,
|
||||
total_amount DECIMAL(14,2) DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. 询价记录
|
||||
CREATE TABLE IF NOT EXISTS inquiry (
|
||||
inquiry_id VARCHAR(50) PRIMARY KEY,
|
||||
customer_code VARCHAR(50) NOT NULL REFERENCES customer(customer_code),
|
||||
inquiry_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
drawing_number VARCHAR(100),
|
||||
part_number VARCHAR(100),
|
||||
annual_volume INTEGER,
|
||||
target_price DECIMAL(10,2),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 3. 询价明细行
|
||||
CREATE TABLE IF NOT EXISTS inquiry_item (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inquiry_id VARCHAR(50) NOT NULL REFERENCES inquiry(inquiry_id),
|
||||
line_no INTEGER NOT NULL,
|
||||
part_name VARCHAR(200),
|
||||
part_number VARCHAR(100),
|
||||
material_grade VARCHAR(50),
|
||||
volume_cc DECIMAL(10,2),
|
||||
annual_qty INTEGER,
|
||||
unit VARCHAR(10) DEFAULT '件',
|
||||
remarks TEXT
|
||||
);
|
||||
|
||||
-- 4. 报价单
|
||||
CREATE TABLE IF NOT EXISTS quotation (
|
||||
quotation_id VARCHAR(50) PRIMARY KEY,
|
||||
inquiry_id VARCHAR(50) REFERENCES inquiry(inquiry_id),
|
||||
customer_code VARCHAR(50) NOT NULL REFERENCES customer(customer_code),
|
||||
quotation_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
valid_until DATE,
|
||||
version INTEGER DEFAULT 1,
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
mold_cost DECIMAL(12,2) DEFAULT 0,
|
||||
unit_price DECIMAL(10,2),
|
||||
annual_volume INTEGER,
|
||||
total_annual DECIMAL(14,2),
|
||||
subtotal DECIMAL(14,2),
|
||||
tax_rate DECIMAL(4,3) DEFAULT 0.130,
|
||||
tax_amount DECIMAL(14,2),
|
||||
total_amount DECIMAL(14,2),
|
||||
currency VARCHAR(10) DEFAULT 'CNY',
|
||||
payment_terms VARCHAR(200) DEFAULT '月结30天',
|
||||
delivery_terms VARCHAR(200) DEFAULT '含税含运',
|
||||
created_by VARCHAR(50) DEFAULT 'AI智能报价',
|
||||
approved_by VARCHAR(50),
|
||||
remarks TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 5. 报价成本明细
|
||||
CREATE TABLE IF NOT EXISTS quotation_cost_detail (
|
||||
id SERIAL PRIMARY KEY,
|
||||
quotation_id VARCHAR(50) NOT NULL REFERENCES quotation(quotation_id),
|
||||
material_cost DECIMAL(10,4),
|
||||
casting_cost DECIMAL(10,4),
|
||||
machining_cost DECIMAL(10,4),
|
||||
post_process_cost DECIMAL(10,4),
|
||||
overhead_rate DECIMAL(4,3),
|
||||
profit_rate DECIMAL(4,3),
|
||||
total_cost DECIMAL(10,4),
|
||||
unit_price DECIMAL(10,4)
|
||||
);
|
||||
|
||||
-- 6. 商机
|
||||
CREATE TABLE IF NOT EXISTS opportunity (
|
||||
opportunity_id VARCHAR(50) PRIMARY KEY,
|
||||
customer_code VARCHAR(50) NOT NULL REFERENCES customer(customer_code),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
stage VARCHAR(20) DEFAULT 'lead',
|
||||
expected_amount DECIMAL(14,2) DEFAULT 0,
|
||||
probability INTEGER DEFAULT 20,
|
||||
oem_program VARCHAR(200),
|
||||
expected_close_date DATE,
|
||||
source VARCHAR(100),
|
||||
created_by VARCHAR(50),
|
||||
created_at DATE DEFAULT CURRENT_DATE,
|
||||
updated_at DATE DEFAULT CURRENT_DATE
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 幂等性:去重 + 唯一约束(可重复执行)
|
||||
-- ============================================================
|
||||
|
||||
DELETE FROM inquiry_item a USING inquiry_item b
|
||||
WHERE a.id > b.id AND a.inquiry_id = b.inquiry_id AND a.line_no = b.line_no;
|
||||
|
||||
DELETE FROM quotation_cost_detail a USING quotation_cost_detail b
|
||||
WHERE a.id > b.id AND a.quotation_id = b.quotation_id;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE inquiry_item ADD CONSTRAINT uq_inquiry_item UNIQUE (inquiry_id, line_no);
|
||||
EXCEPTION WHEN duplicate_table THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE quotation_cost_detail ADD CONSTRAINT uq_quotation_cost UNIQUE (quotation_id);
|
||||
EXCEPTION WHEN duplicate_table THEN NULL; END $$;
|
||||
|
||||
-- 多版本报价支持:同一询价单不限报价次数
|
||||
-- 1) 规范化历史版本号:同一询价单下按时间序重写为 1..n(幂等,已是 1..n 的行不变)
|
||||
WITH ranked AS (
|
||||
SELECT quotation_id,
|
||||
ROW_NUMBER() OVER (PARTITION BY inquiry_id ORDER BY created_at, quotation_id) AS rn
|
||||
FROM quotation
|
||||
WHERE inquiry_id IS NOT NULL
|
||||
)
|
||||
UPDATE quotation q
|
||||
SET version = r.rn
|
||||
FROM ranked r
|
||||
WHERE q.quotation_id = r.quotation_id AND q.version <> r.rn;
|
||||
|
||||
-- 2) 同一询价单内版本号唯一(并发防重)
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE quotation ADD CONSTRAINT uq_quotation_version UNIQUE (inquiry_id, version);
|
||||
EXCEPTION WHEN duplicate_table THEN NULL; END $$;
|
||||
|
||||
-- 3) 版本链查询索引
|
||||
CREATE INDEX IF NOT EXISTS idx_quotation_inquiry ON quotation(inquiry_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据
|
||||
-- ============================================================
|
||||
|
||||
-- 客户(20 家:OEM×7 / Tier1×7 / Tier2×6;信用 A×8 / B×6 / C×2 / D×1 / 无评级×3)
|
||||
INSERT INTO customer (customer_code, name, oem_tier, credit_level, discount_rate, payment_days, contact_person, phone, email, industry, region, total_orders, total_amount, notes) VALUES
|
||||
('OEM-2024-001', '某日系主机厂', 'OEM', 'A', 0.050, 60, '张工', '021-5555-0001', 'zhang@oem-jp.example.com', '汽车', '上海', 45, 28000000, '长期合作,主要采购铝合金阀体壳'),
|
||||
('OEM-2024-002', '某德系主机厂', 'OEM', 'A', 0.030, 45, '李总', '010-8888-0002', 'li@oem-de.example.com', '汽车', '北京', 30, 15000000, '质量要求高,偏好铸铁件'),
|
||||
('OEM-2024-003', '某新能源主机厂', 'OEM', 'A', 0.040, 60, '刘工', '0551-2222-0005', 'liu@oem-nev.example.com', '汽车', '合肥', 22, 9800000, '新能源三电系统配套,增长快'),
|
||||
('OEM-2024-004', '某美系主机厂', 'OEM', 'A', 0.030, 45, '孙总', '027-3333-0006', 'sun@oem-us.example.com', '汽车', '武汉', 26, 11200000, '混合动力变速箱项目'),
|
||||
('OEM-2025-001', '某商用车主机厂', 'OEM', 'B', 0.020, 90, '赵工', '0431-4444-0007', 'zhao@oem-cv.example.com', '商用车', '长春', 12, 4600000, '长账期客户,注意现金流'),
|
||||
('OEM-2025-002', '某乘商两用主机厂', 'OEM', 'B', 0.025, 60, '周工', '0772-5555-0008', 'zhou@oem-mpv.example.com', '汽车', '柳州', 9, 3200000, '乘用车+商用车双线采购'),
|
||||
('OEM-2026-001', '某智能电动新势力', 'OEM', 'A', 0.050, 45, '吴总', '021-6666-0009', 'wu@oem-ev.example.com', '新能源', '上海', 3, 850000, '高折扣新OEM,战略培育客户'),
|
||||
('T1-2025-001', '某博世供应商', 'Tier1', 'B', 0.020, 30, '王经理','0512-6666-0003', 'wang@tier1-bs.example.com', '汽车零部件', '江苏苏州', 18, 5600000, '变速箱配套阀体'),
|
||||
('T1-2025-002', '某大陆供应商', 'Tier1', 'B', 0.000, 30, '陈工', '020-7777-0004', 'chen@tier1-cr.example.com', '汽车零部件', '广东广州', 8, 1200000, '制动系统配套,新开发客户'),
|
||||
('T1-2025-003', '某采埃孚供应商', 'Tier1', 'A', 0.020, 45, '郑总', '021-7777-0010', 'zheng@tier1-zf.example.com', '汽车零部件', '上海', 15, 4800000, 'ZF悬架系统配套'),
|
||||
('T1-2025-004', '某电装系供应商', 'Tier1', 'A', 0.025, 45, '林总', '020-8888-0011', 'lin@tier1-ds.example.com', '汽车零部件', '广州', 20, 6300000, '日系热管理系统配套'),
|
||||
('T1-2025-005', '某日立安斯泰莫供应商', 'Tier1', 'A', 0.020, 45, '徐总', '0411-9999-0012', 'xu@tier1-ha.example.com', '汽车零部件', '大连', 14, 4100000, '日系制动/转向配套'),
|
||||
('T1-2026-001', '某制动系统供应商', 'Tier1', 'B', 0.015, 30, '何工', '023-1111-0013', 'he@tier1-bk.example.com', '汽车零部件', '重庆', 6, 1500000, '国产线控制动项目'),
|
||||
('T1-2026-002', '某液压系统供应商', 'Tier1', 'B', 0.010, 60, '高工', '0731-2222-0014', 'gao@tier1-hy.example.com', '工程机械', '长沙', 5, 980000, '液压阀体,账期较长'),
|
||||
('T2-2025-001', '某售后市场贸易商', 'Tier2', 'C', 0.000, 0, '罗经理','0571-3333-0015', 'luo@am-trade.example.com', '售后市场', '杭州', 3, 210000, '货到付款,信用C级,注意风险'),
|
||||
('T2-2025-002', '某智能底盘初创公司', 'Tier2', '无评级', 0.000, 30, '宋工', '0512-4444-0016', 'song@chassis.example.com', '智能底盘', '苏州', 0, 0, '零订单历史新客户,需信用评审'),
|
||||
('T2-2025-003', '某非道路机械贸易商', 'Tier2', 'D', 0.000, 0, '蒋经理','0537-5555-0017', 'jiang@offroad.example.com', '非道路机械', '济宁', 1, 45000, '信用D级,仅接受预付款订单'),
|
||||
('T2-2025-004', '某改装件连锁商', 'Tier2', 'C', 0.000, 0, '邓总', '028-5555-0018', 'deng@mod-chain.example.com', '售后市场', '成都', 5, 320000, '改装市场连锁,货到付款'),
|
||||
('T2-2026-001', '某机器人关节初创公司', 'Tier2', '无评级', 0.000, 30, '冯工', '0755-6666-0019', 'feng@robot-joint.example.com','机器人', '深圳', 0, 0, '零历史,人形机器人关节部件'),
|
||||
('T2-2026-002', '某低空经济无人机公司', 'Tier2', '无评级', 0.000, 30, '曹工', '0755-7777-0020', 'cao@uav.example.com', '低空经济', '深圳', 0, 0, '新兴行业,eVTOL液压部件询价')
|
||||
ON CONFLICT (customer_code) DO NOTHING;
|
||||
|
||||
-- 历史询价(2024-2025 闭环 + 2026 在途)
|
||||
INSERT INTO inquiry (inquiry_id, customer_code, inquiry_date, drawing_number, part_number, annual_volume, target_price, status, notes) VALUES
|
||||
('INQ-2024-9005', 'OEM-2024-001', '2024-10-20', 'DWG-VHB-EG-Rev1', 'P-VHB-004', 200000, 55.00, 'completed', '2024年发动机油路阀体壳询价,已成交'),
|
||||
('INQ-2025-9001', 'OEM-2024-001', '2025-09-25', 'DWG-VBH-AT-Rev2', 'P-VBH-001', 300000, 96.00, 'completed', '2025年变速箱阀体外壳年价谈判'),
|
||||
('INQ-2025-9002', 'OEM-2024-002', '2025-08-10', 'DWG-VHB-BK-Rev2', 'P-VHB-002', 150000, 155.00, 'completed', '2025年制动阀体壳询价,已中标'),
|
||||
('INQ-2025-9003', 'T1-2025-001', '2025-05-28', 'DWG-VHB-SP-Rev1', 'P-VHB-003', 150000, 25.00, 'lost', '转向器阀体壳,因报价偏高丢单'),
|
||||
('INQ-2025-9006', 'OEM-2024-002', '2025-11-05', 'DWG-VBH-AT-Rev2', 'P-VBH-001', 100000, 100.00, 'completed', '德系小批量采购变速箱阀体外壳'),
|
||||
('INQ-2026-0006', 'OEM-2024-004', '2026-08-05', 'DWG-VHB-TC-Rev1', 'P-VHB-011', 120000, 48.00, 'pending', '涡轮增压控制阀体壳新询价'),
|
||||
('INQ-2026-0007', 'OEM-2025-001', '2026-08-06', 'DWG-VHB-HD-Rev1', 'P-VHB-016', 60000, 260.00, 'processing', '重卡制动阀体壳,1000T大件')
|
||||
ON CONFLICT (inquiry_id) DO NOTHING;
|
||||
|
||||
-- 询价明细行(与询价单一一对应)
|
||||
INSERT INTO inquiry_item (inquiry_id, line_no, part_name, part_number, material_grade, volume_cc, annual_qty, remarks) VALUES
|
||||
('INQ-2024-9005', 1, '发动机油路阀体壳', 'P-VHB-004', 'EN AC-4600', 555.56, 200000, '400T压铸,阳极氧化'),
|
||||
('INQ-2025-9001', 1, '变速箱阀体外壳', 'P-VBH-001', 'ADC12', 444.44, 300000, '400T压铸,阳极氧化'),
|
||||
('INQ-2025-9002', 1, '制动阀体壳', 'P-VHB-002', 'HT250', 388.89, 150000, '630T压铸,电泳涂装'),
|
||||
('INQ-2025-9003', 1, '转向器阀体壳', 'P-VHB-003', 'A356-T6', 296.30, 150000, '280T压铸,钝化'),
|
||||
('INQ-2025-9006', 1, '变速箱阀体外壳', 'P-VBH-001', 'ADC12', 444.44, 100000, '小批量'),
|
||||
('INQ-2026-0006', 1, '涡轮增压控制阀体壳', 'P-VHB-011', 'ADC12', 320.00, 120000, '350T压铸,钝化'),
|
||||
('INQ-2026-0007', 1, '重卡制动阀体壳', 'P-VHB-016', 'HT300', 850.00, 60000, '1000T压铸,达克罗')
|
||||
ON CONFLICT (inquiry_id, line_no) DO NOTHING;
|
||||
|
||||
-- 基础询价(保留原种子)
|
||||
INSERT INTO inquiry (inquiry_id, customer_code, inquiry_date, drawing_number, part_number, annual_volume, target_price, status, notes) VALUES
|
||||
('INQ-2026-0001', 'OEM-2024-001', '2026-08-01', 'DWG-VBH-AT-Rev3', 'P-VBH-001', 300000, 95.00, 'processing', '新车型配套阀体壳,要求阳极氧化'),
|
||||
('INQ-2026-0002', 'T1-2025-001', '2026-08-03', 'DWG-VHB-SP-Rev1', 'P-VHB-003', 150000, 55.00, 'pending', '转向器阀体壳,钝化处理')
|
||||
ON CONFLICT (inquiry_id) DO NOTHING;
|
||||
|
||||
INSERT INTO inquiry_item (inquiry_id, line_no, part_name, part_number, material_grade, volume_cc, annual_qty, remarks) VALUES
|
||||
('INQ-2026-0001', 1, '变速箱阀体外壳', 'P-VBH-001', 'ADC12', 444.44, 300000, '体积444.44cm³,400T压铸'),
|
||||
('INQ-2026-0002', 1, '转向器阀体壳', 'P-VHB-003', 'A356-T6', 296.30, 150000, '体积296.30cm³,280T压铸')
|
||||
ON CONFLICT (inquiry_id, line_no) DO NOTHING;
|
||||
|
||||
-- 历史报价(2024Q4-2025,含 won/lost,用于价格趋势与一致性校验)
|
||||
INSERT INTO quotation (quotation_id, inquiry_id, customer_code, quotation_date, valid_until, version, status, mold_cost, unit_price, annual_volume, total_annual, subtotal, tax_rate, tax_amount, total_amount, payment_terms, delivery_terms, created_by, approved_by, remarks) VALUES
|
||||
('QUO-2024-9005', 'INQ-2024-9005', 'OEM-2024-001', '2024-11-08', '2024-12-08', 1, 'approved', 180000, 52.30, 200000, 10460000, 10460000, 0.130, 1359800, 11819800, '月结60天', '含税含运', 'AI智能报价', '销售经理-赵总', '2024年基线价'),
|
||||
('QUO-2025-9001', 'INQ-2025-9001', 'OEM-2024-001', '2025-10-15', '2025-11-15', 1, 'approved', 0, 42.50, 300000, 12750000, 12750000, 0.130, 1657500, 14407500, '月结60天', '含税含运', 'AI智能报价', '销售经理-赵总', '2025年年价,模具费已摊销'),
|
||||
('QUO-2025-9002', 'INQ-2025-9001', 'OEM-2024-001', '2025-12-20', '2026-01-20', 2, 'approved', 0, 41.90, 300000, 12570000, 12570000, 0.130, 1634100, 14204100, '月结60天', '含税含运', 'AI智能报价', '销售经理-赵总', '第二轮降价,一致性校验基准'),
|
||||
('QUO-2025-9003', 'INQ-2025-9002', 'OEM-2024-002', '2025-09-05', '2025-10-05', 1, 'won', 250000, 46.80, 150000, 7020000, 7020000, 0.130, 912600, 7932600, '月结45天', '含税含运,北京交货', 'AI智能报价', '销售总监-钱总', '已中标'),
|
||||
('QUO-2025-9004', 'INQ-2025-9003', 'T1-2025-001', '2025-06-18', '2025-07-18', 1, 'lost', 120000, 28.40, 150000, 4260000, 4260000, 0.130, 553800, 4813800, '月结30天', '含税含运', 'AI智能报价', NULL, '报价偏高丢单,客户目标价25元'),
|
||||
('QUO-2025-9006', 'INQ-2025-9006', 'OEM-2024-002', '2025-11-30', '2025-12-30', 1, 'sent', 0, 43.20, 100000, 4320000, 4320000, 0.130, 561600, 4881600, '月结45天', '含税含运', 'AI智能报价', '销售经理-赵总', '跨客户价差校验样本')
|
||||
ON CONFLICT (quotation_id) DO NOTHING;
|
||||
|
||||
-- 历史报价成本明细
|
||||
INSERT INTO quotation_cost_detail (quotation_id, material_cost, casting_cost, machining_cost, post_process_cost, overhead_rate, profit_rate, total_cost, unit_price) VALUES
|
||||
('QUO-2024-9005', 28.50, 2.17, 2.45, 7.50, 0.120, 0.150, 40.62, 52.30),
|
||||
('QUO-2025-9001', 23.90, 2.17, 0.87, 6.00, 0.120, 0.150, 32.94, 42.50),
|
||||
('QUO-2025-9002', 23.50, 2.17, 0.87, 6.00, 0.120, 0.150, 32.54, 41.90),
|
||||
('QUO-2025-9003', 20.20, 4.43, 1.91, 9.80, 0.120, 0.150, 36.34, 46.80),
|
||||
('QUO-2025-9004', 16.50, 1.10, 2.88, 1.57, 0.120, 0.280, 22.05, 28.40),
|
||||
('QUO-2025-9006', 24.50, 2.17, 0.87, 6.00, 0.120, 0.150, 33.54, 43.20)
|
||||
ON CONFLICT (quotation_id) DO NOTHING;
|
||||
|
||||
-- 基础报价(保留原种子)
|
||||
INSERT INTO quotation (quotation_id, inquiry_id, customer_code, quotation_date, valid_until, version, status, mold_cost, unit_price, annual_volume, total_annual, subtotal, tax_rate, tax_amount, total_amount, payment_terms, delivery_terms, created_by, approved_by, remarks) VALUES
|
||||
('QUO-2026-0001', 'INQ-2026-0001', 'OEM-2024-001', '2026-07-15', '2026-08-15', 1, 'sent',
|
||||
180000, 92.50, 300000, 27750000, 27750000, 0.130, 3607500, 31357500,
|
||||
'月结60天', '含税含运,上海交货', 'AI智能报价', '销售经理-赵总', '老客户优惠价,含模具费18万')
|
||||
ON CONFLICT (quotation_id) DO NOTHING;
|
||||
|
||||
INSERT INTO quotation_cost_detail (quotation_id, material_cost, casting_cost, machining_cost, post_process_cost, overhead_rate, profit_rate, total_cost, unit_price) VALUES
|
||||
('QUO-2026-0001', 24.50, 8.20, 35.60, 6.00, 0.120, 0.150, 74.30, 92.50)
|
||||
ON CONFLICT (quotation_id) DO NOTHING;
|
||||
|
||||
-- 商机(完整阶段谱系:lead/qualified/proposal/negotiation/quoted/won/lost)
|
||||
INSERT INTO opportunity (opportunity_id, customer_code, title, stage, expected_amount, probability, oem_program, expected_close_date, source, created_by) VALUES
|
||||
('OPP-2025-0005', 'OEM-2024-002', '2025年制动阀体壳定点', 'won', 8200000, 100, '德系新制动系统平台', '2025-09-30', '客户主动询价', '销售团队'),
|
||||
('OPP-2025-0006', 'T1-2025-001', '转向器阀体壳年度供应', 'lost', 4500000, 0, 'EPS转向系统', '2025-07-31', '展会获客', '销售团队'),
|
||||
('OPP-2026-0003', 'T1-2025-003', '采埃孚悬架阀体项目', 'lead', 5000000, 20, '主动悬架系统配套', '2026-12-31', '客户推荐', 'AI智能报价'),
|
||||
('OPP-2026-0004', 'OEM-2024-003', '新能源平台阀体定点', 'negotiation', 9000000, 60, '纯电平台热管理+制动', '2026-11-30', '战略客户开发', '销售团队'),
|
||||
('OPP-2026-0005', 'OEM-2024-004', '美系OEM新项目询价', 'qualified', 12000000, 40, '下一代混动变速箱', '2027-03-31', '客户主动询价', 'AI智能报价'),
|
||||
('OPP-2026-0006', 'OEM-2025-001', '商用车平台重卡阀体', 'lead', 6000000, 15, '重卡制动系统升级', '2026-12-31', '展会获客', '销售团队'),
|
||||
('OPP-2026-0001', 'OEM-2024-001', '2026年新车型阀体壳定点', 'proposal', 27750000, 60, '某日系新车型CVT配套', '2026-10-31', '客户主动询价', 'AI智能报价'),
|
||||
('OPP-2026-0002', 'T1-2025-001', '转向器阀体壳年度采购', 'lead', 8250000, 30, 'EPS转向系统配套', '2026-12-31', '展会获客', 'AI智能报价')
|
||||
ON CONFLICT (opportunity_id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 报价写入 CRM Mock 表(独立模拟场景,无外键、与其他工具无关)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS quotation_mock (
|
||||
quotation_id VARCHAR(50) PRIMARY KEY,
|
||||
customer_name VARCHAR(200),
|
||||
part_name VARCHAR(200),
|
||||
part_number VARCHAR(50),
|
||||
material_grade VARCHAR(50),
|
||||
mold_cost DECIMAL(12,2) DEFAULT 0,
|
||||
unit_price DECIMAL(10,2),
|
||||
annual_volume INTEGER,
|
||||
total_annual DECIMAL(14,2),
|
||||
currency VARCHAR(10) DEFAULT 'CNY',
|
||||
quotation_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
remarks TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1,119 +0,0 @@
|
||||
"""动态 Bearer Token 鉴权(通过 mcp-auth 后端 API 校验,不直连鉴权库)。
|
||||
|
||||
token 校验由 MCP 服务调用 mcp-auth 后端 `POST /api/auth/verify-token` 完成,
|
||||
后端统一查 mcp_auth.mcp_token 表。后端通过 per-service API Key 识别调用方服务,
|
||||
MCP 服务无需自报 service。进程内 LRU 缓存 30s,减少 API 调用。
|
||||
|
||||
环境变量:
|
||||
MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port}
|
||||
MCP_AUTH_API_URL mcp-auth 后端地址,如 http://localhost:8000
|
||||
MCP_AUTH_API_KEY 本服务的 API Key(由 mcp-auth 后端签发,per-service 唯一)
|
||||
MCP_AUTH_CACHE_TTL 缓存秒数,默认 30
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
|
||||
_CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30"))
|
||||
|
||||
# httpx 异步客户端(进程级单例,复用连接池)
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None:
|
||||
_http_client = httpx.AsyncClient(timeout=5.0)
|
||||
return _http_client
|
||||
|
||||
|
||||
async def close_auth_client() -> None:
|
||||
"""关闭 httpx 客户端(进程退出时调用)。"""
|
||||
global _http_client
|
||||
if _http_client is not None:
|
||||
await _http_client.aclose()
|
||||
_http_client = None
|
||||
|
||||
|
||||
class ApiTokenVerifier:
|
||||
"""通过 mcp-auth 后端 API 校验 Bearer Token。
|
||||
|
||||
进程内 LRU 缓存(token_hash -> (valid, client_id, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
|
||||
"""
|
||||
|
||||
def __init__(self, api_url: str, api_key: str):
|
||||
self._api_url = api_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._cache: dict[str, tuple[bool, str | None, float]] = {}
|
||||
|
||||
def invalidate(self, token: str | None = None) -> None:
|
||||
"""清缓存:token=None 清全部,否则清单个。"""
|
||||
if token is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(hashlib.sha256(token.encode()).hexdigest(), None)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
# 1. 查缓存
|
||||
now = time.time()
|
||||
cached = self._cache.get(token_hash)
|
||||
if cached is not None and (now - cached[2]) < _CACHE_TTL:
|
||||
valid, client_id, _ = cached
|
||||
if not valid:
|
||||
return None
|
||||
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
|
||||
|
||||
# 2. 调用后端校验 API(后端从 X-API-Key 识别 service,无需传 service)
|
||||
try:
|
||||
client = _get_http_client()
|
||||
resp = await client.post(
|
||||
f"{self._api_url}/api/auth/verify-token",
|
||||
headers={"X-API-Key": self._api_key},
|
||||
json={"token": token},
|
||||
)
|
||||
except Exception:
|
||||
# 后端不可达,缓存短时间避免雪崩
|
||||
self._cache[token_hash] = (False, None, now)
|
||||
return None
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
valid = data.get("valid", False)
|
||||
client_id = data.get("client_id")
|
||||
self._cache[token_hash] = (valid, client_id, now)
|
||||
if valid:
|
||||
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
|
||||
return None
|
||||
else:
|
||||
# 非 200(含 401 API Key 错误),缓存避免雪崩
|
||||
self._cache[token_hash] = (False, None, now)
|
||||
return None
|
||||
|
||||
|
||||
def get_auth(port: int) -> tuple[AuthSettings, ApiTokenVerifier]:
|
||||
"""构建 MCPServer 的 (auth, token_verifier) 参数。
|
||||
|
||||
service 由后端从 API Key 自动识别,MCP 服务无需配置 MCP_AUTH_SERVICE。
|
||||
"""
|
||||
url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}"
|
||||
api_url = os.getenv("MCP_AUTH_API_URL", "http://localhost:8000")
|
||||
api_key = os.getenv("MCP_AUTH_API_KEY", "")
|
||||
return (
|
||||
AuthSettings(issuer_url=url, resource_server_url=url),
|
||||
ApiTokenVerifier(api_url=api_url, api_key=api_key),
|
||||
)
|
||||
|
||||
|
||||
def get_caller() -> str:
|
||||
"""工具内获取当前调用方标识(未认证时返回 anonymous)。"""
|
||||
access_token = get_access_token()
|
||||
return access_token.client_id if access_token else "anonymous"
|
||||
@@ -1,28 +0,0 @@
|
||||
"""PostgreSQL 连接池管理"""
|
||||
|
||||
import asyncpg
|
||||
import os
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("CRM_DB_HOST", "10.100.154.100"),
|
||||
"port": int(os.getenv("CRM_DB_PORT", "5432")),
|
||||
"user": os.getenv("CRM_DB_USER", "postgres"),
|
||||
"password": os.getenv("CRM_DB_PASSWORD", "digiwin"),
|
||||
"database": os.getenv("CRM_DB_NAME", "smart_quotation_auto"),
|
||||
}
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""初始化 CRM 数据库表和种子数据"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from db import get_pool, close_pool
|
||||
|
||||
async def main():
|
||||
pool = await get_pool()
|
||||
sql_path = Path(__file__).parent.parent / "sql" / "init.sql"
|
||||
sql = sql_path.read_text(encoding="utf-8")
|
||||
await pool.execute(sql)
|
||||
print("CRM 数据库初始化完成")
|
||||
await close_pool()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,543 +0,0 @@
|
||||
"""
|
||||
MCP for CRM — 汽车零部件智能报价 CRM 数据服务
|
||||
使用 MCPServer 框架,5 个工具:客户查询、询价建档、报价存档、历史报价、商机管理
|
||||
HTTP 模式,支持远程调用
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
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=8002)
|
||||
|
||||
app = MCPServer(
|
||||
name="mcp-for-crm-auto",
|
||||
description="汽车零部件智能报价 CRM 数据服务",
|
||||
version="1.0.0",
|
||||
auth=_auth,
|
||||
token_verifier=_token_verifier,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 1: get_customer_info — 查询客户信息
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def get_customer_info(
|
||||
customer_code: str = None,
|
||||
name: str = None,
|
||||
oem_tier: str = None
|
||||
) -> str:
|
||||
"""查询 CRM 客户信息。支持按客户编码精确查询或按名称、OEM 层级模糊搜索。返回客户信用等级、折扣率、历史订单等。"""
|
||||
# 查询语句处 str 归一化:空串/纯空白同 None
|
||||
customer_code = _s(customer_code)
|
||||
name = _s(name)
|
||||
oem_tier = _s(oem_tier)
|
||||
pool = await get_pool()
|
||||
query = "SELECT * FROM customer WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if customer_code:
|
||||
query += f" AND customer_code = ${len(params)+1}"
|
||||
params.append(customer_code)
|
||||
if name:
|
||||
query += f" AND name LIKE ${len(params)+1}"
|
||||
params.append(f"%{name}%")
|
||||
if oem_tier:
|
||||
query += f" AND oem_tier = ${len(params)+1}"
|
||||
params.append(oem_tier)
|
||||
|
||||
rows = await pool.fetch(query, *params)
|
||||
customers = [dict(row) for row in rows]
|
||||
|
||||
return json.dumps({"total": len(customers), "customers": customers}, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 2: create_inquiry — 创建询价记录
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def create_inquiry(
|
||||
customer_code: str,
|
||||
drawing_number: str = None,
|
||||
part_number: str = None,
|
||||
annual_volume: int = None,
|
||||
target_price: float = None,
|
||||
notes: str = None
|
||||
) -> str:
|
||||
"""在 CRM 中创建新的询价记录。关联客户、图纸号、零件号、年用量。客户提交询价后建档使用。"""
|
||||
# 查询语句处 str 归一化:空串/纯空白同 None
|
||||
customer_code = _s(customer_code)
|
||||
drawing_number = _s(drawing_number)
|
||||
part_number = _s(part_number)
|
||||
notes = _s(notes)
|
||||
pool = await get_pool()
|
||||
|
||||
customer = await pool.fetchrow("SELECT name FROM customer WHERE customer_code = $1", customer_code)
|
||||
if not customer:
|
||||
return json.dumps({"success": False, "message": f"未找到客户 {customer_code}"}, ensure_ascii=False, indent=2)
|
||||
|
||||
max_seq = await pool.fetchval(
|
||||
"SELECT COALESCE(MAX(CAST(SUBSTRING(inquiry_id FROM '(\\d+)$') AS INTEGER)), 0) FROM inquiry WHERE inquiry_id LIKE 'INQ-2026-%'"
|
||||
)
|
||||
inquiry_id = f"INQ-2026-{str(max_seq + 1).zfill(4)}"
|
||||
|
||||
await pool.execute(
|
||||
"""INSERT INTO inquiry (inquiry_id, customer_code, inquiry_date, drawing_number, part_number, annual_volume, target_price, status, notes)
|
||||
VALUES ($1, $2, CURRENT_DATE, $3, $4, $5, $6, 'pending', $7)""",
|
||||
inquiry_id, customer_code, drawing_number, part_number,
|
||||
annual_volume, target_price, notes
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"success": True, "inquiry_id": inquiry_id, "customer_name": customer["name"],
|
||||
"status": "pending", "message": f"询价记录 {inquiry_id} 已创建"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 3: save_quotation — 保存报价单
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def save_quotation(
|
||||
inquiry_id: str,
|
||||
customer_code: str,
|
||||
mold_cost: float,
|
||||
unit_price: float,
|
||||
annual_volume: int,
|
||||
material_cost: float,
|
||||
casting_cost: float,
|
||||
machining_cost: float,
|
||||
post_process_cost: float,
|
||||
overhead_rate: float = 0.12,
|
||||
profit_rate: float = 0.15,
|
||||
tax_rate: float = 0.13,
|
||||
payment_terms: str = "月结30天",
|
||||
delivery_terms: str = "含税含运",
|
||||
valid_days: int = 30,
|
||||
remarks: str = None
|
||||
) -> str:
|
||||
"""保存报价单到 CRM。包含模具费、单价、年用量、成本明细。报价生成后存档使用。"""
|
||||
# 查询语句处 str 归一化:空串/纯空白同 None
|
||||
inquiry_id = _s(inquiry_id)
|
||||
customer_code = _s(customer_code)
|
||||
payment_terms = _s(payment_terms)
|
||||
delivery_terms = _s(delivery_terms)
|
||||
remarks = _s(remarks)
|
||||
pool = await get_pool()
|
||||
|
||||
customer = await pool.fetchrow("SELECT name FROM customer WHERE customer_code = $1", customer_code)
|
||||
if not customer:
|
||||
return json.dumps({"success": False, "message": f"未找到客户 {customer_code}"}, ensure_ascii=False, indent=2)
|
||||
|
||||
inquiry = await pool.fetchrow("SELECT inquiry_id, status FROM inquiry WHERE inquiry_id = $1", inquiry_id)
|
||||
if not inquiry:
|
||||
return json.dumps({"success": False, "message": f"未找到询价单 {inquiry_id},请先调用 create_inquiry"}, ensure_ascii=False, indent=2)
|
||||
|
||||
# 多版本报价:同一询价单不限报价次数,版本号自动递增
|
||||
prev = await pool.fetchrow(
|
||||
"SELECT quotation_id, version, unit_price, status FROM quotation WHERE inquiry_id = $1 ORDER BY version DESC LIMIT 1",
|
||||
inquiry_id
|
||||
)
|
||||
new_version = (prev["version"] + 1) if prev else 1
|
||||
|
||||
price_change_pct = None
|
||||
if prev and prev["unit_price"]:
|
||||
price_change_pct = round((unit_price - float(prev["unit_price"])) / float(prev["unit_price"]) * 100, 2)
|
||||
|
||||
warning = None
|
||||
if prev and prev["status"] == "won":
|
||||
warning = f"该询价单已有中标报价 {prev['quotation_id']},本次作为补充报价存档"
|
||||
|
||||
max_seq = await pool.fetchval(
|
||||
"SELECT COALESCE(MAX(CAST(SUBSTRING(quotation_id FROM '(\\d+)$') AS INTEGER)), 0) FROM quotation WHERE quotation_id LIKE 'QUO-2026-%'"
|
||||
)
|
||||
quotation_id = f"QUO-2026-{str(max_seq + 1).zfill(4)}"
|
||||
|
||||
today = datetime.now()
|
||||
valid_until = today + timedelta(days=valid_days)
|
||||
|
||||
total_annual = unit_price * annual_volume
|
||||
subtotal = total_annual
|
||||
tax_amount = subtotal * tax_rate
|
||||
total_amount = subtotal + tax_amount
|
||||
|
||||
total_cost = material_cost + casting_cost + machining_cost + post_process_cost
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# 旧版本自动作废(won/lost 终态保留)
|
||||
if prev:
|
||||
await conn.execute(
|
||||
"""UPDATE quotation SET status = 'superseded'
|
||||
WHERE inquiry_id = $1 AND version < $2 AND status NOT IN ('won', 'lost', 'superseded')""",
|
||||
inquiry_id, new_version
|
||||
)
|
||||
# 首次报价推动询价单状态 pending → quoted
|
||||
await conn.execute(
|
||||
"UPDATE inquiry SET status = 'quoted' WHERE inquiry_id = $1 AND status = 'pending'",
|
||||
inquiry_id
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO quotation (quotation_id, inquiry_id, customer_code, quotation_date, valid_until, version, status,
|
||||
mold_cost, unit_price, annual_volume, total_annual, subtotal, tax_rate, tax_amount, total_amount,
|
||||
payment_terms, delivery_terms, remarks, created_by)
|
||||
VALUES ($1, $2, $3, CURRENT_DATE, $4, $5, 'draft', $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'AI智能报价')""",
|
||||
quotation_id, inquiry_id, customer_code, valid_until.date(), new_version, mold_cost, unit_price,
|
||||
annual_volume, total_annual, subtotal, tax_rate, tax_amount, total_amount,
|
||||
payment_terms, delivery_terms, remarks
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO quotation_cost_detail (quotation_id, material_cost, casting_cost, machining_cost, post_process_cost,
|
||||
overhead_rate, profit_rate, total_cost, unit_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
||||
quotation_id, material_cost, casting_cost, machining_cost,
|
||||
post_process_cost, overhead_rate, profit_rate, total_cost, unit_price
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True, "quotation_id": quotation_id, "status": "draft",
|
||||
"version": new_version,
|
||||
"previous_quotation_id": prev["quotation_id"] if prev else None,
|
||||
"price_change_pct": price_change_pct,
|
||||
"summary": {
|
||||
"mold_cost": mold_cost, "unit_price": unit_price, "annual_volume": annual_volume,
|
||||
"total_annual": total_annual, "tax_rate": f"{tax_rate*100:.0f}%", "tax_amount": tax_amount,
|
||||
"total_amount": total_amount, "currency": "CNY"
|
||||
},
|
||||
"valid_until": valid_until.strftime("%Y-%m-%d"),
|
||||
"message": f"报价单 {quotation_id}(第 {new_version} 版)已保存为草稿,待人工审核后发出"
|
||||
}
|
||||
if warning:
|
||||
result["warning"] = warning
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 4: get_quotation_history — 查询历史报价
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def get_quotation_history(
|
||||
customer_code: str = None,
|
||||
inquiry_id: str = None,
|
||||
start_date: str = None,
|
||||
end_date: str = None,
|
||||
status: str = None,
|
||||
latest_only: bool = False
|
||||
) -> str:
|
||||
"""查询历史报价记录。用于核价参考和价格一致性校验。支持按客户、询价编号、日期范围筛选。latest_only=true 时每个询价单仅返回最新版本。"""
|
||||
# 查询语句处 str 归一化:空串/纯空白同 None
|
||||
customer_code = _s(customer_code)
|
||||
inquiry_id = _s(inquiry_id)
|
||||
start_date = _s(start_date)
|
||||
end_date = _s(end_date)
|
||||
status = _s(status)
|
||||
pool = await get_pool()
|
||||
select_clause = (
|
||||
"SELECT DISTINCT ON (q.inquiry_id) q.*, c.name as customer_name"
|
||||
if latest_only
|
||||
else "SELECT q.*, c.name as customer_name"
|
||||
)
|
||||
query = select_clause + " FROM quotation q JOIN customer c ON q.customer_code = c.customer_code WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if customer_code:
|
||||
query += f" AND q.customer_code = ${len(params)+1}"
|
||||
params.append(customer_code)
|
||||
if inquiry_id:
|
||||
query += f" AND q.inquiry_id = ${len(params)+1}"
|
||||
params.append(inquiry_id)
|
||||
if start_date:
|
||||
query += f" AND q.quotation_date >= ${len(params)+1}"
|
||||
params.append(datetime.strptime(start_date, "%Y-%m-%d").date())
|
||||
if end_date:
|
||||
query += f" AND q.quotation_date <= ${len(params)+1}"
|
||||
params.append(datetime.strptime(end_date, "%Y-%m-%d").date())
|
||||
if status:
|
||||
query += f" AND q.status = ${len(params)+1}"
|
||||
params.append(status)
|
||||
|
||||
# 版本链视图:同一询价单按版本号降序排列(DISTINCT ON 要求 ORDER BY 以 inquiry_id 开头)
|
||||
query += " ORDER BY q.inquiry_id, q.version DESC, q.quotation_date DESC"
|
||||
rows = await pool.fetch(query, *params)
|
||||
quotations = [dict(row) for row in rows]
|
||||
|
||||
# 标记每条报价是否为其询价单的最新版本(全表口径,不受筛选影响)
|
||||
inq_ids = list({q["inquiry_id"] for q in quotations if q.get("inquiry_id")})
|
||||
max_versions = {}
|
||||
if inq_ids:
|
||||
mv_rows = await pool.fetch(
|
||||
"SELECT inquiry_id, MAX(version) AS mv FROM quotation WHERE inquiry_id = ANY($1) GROUP BY inquiry_id",
|
||||
inq_ids
|
||||
)
|
||||
max_versions = {r["inquiry_id"]: r["mv"] for r in mv_rows}
|
||||
for q in quotations:
|
||||
q["is_latest"] = bool(q.get("inquiry_id")) and q["version"] == max_versions.get(q["inquiry_id"])
|
||||
|
||||
for q in quotations:
|
||||
cost_detail = await pool.fetchrow("SELECT * FROM quotation_cost_detail WHERE quotation_id = $1", q["quotation_id"])
|
||||
if cost_detail:
|
||||
q["cost_detail"] = dict(cost_detail)
|
||||
|
||||
return json.dumps({"total": len(quotations), "quotations": quotations}, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 5: update_opportunity — 更新商机状态
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def update_opportunity(
|
||||
opportunity_id: str = None,
|
||||
customer_code: str = None,
|
||||
title: str = None,
|
||||
stage: str = None,
|
||||
expected_amount: float = None,
|
||||
probability: int = None,
|
||||
oem_program: str = None,
|
||||
expected_close_date: str = None,
|
||||
notes: str = None
|
||||
) -> str:
|
||||
"""更新 CRM 中的商机状态。支持创建新商机或更新已有商机的阶段、金额、概率。报价后跟进使用。"""
|
||||
# 查询语句处 str 归一化:空串/纯空白同 None。str 变为 None 后,
|
||||
# 后面 if value is not None 会自然跳过,避免空串被当作有效值写入。
|
||||
opportunity_id = _s(opportunity_id)
|
||||
customer_code = _s(customer_code)
|
||||
title = _s(title)
|
||||
stage = _s(stage)
|
||||
oem_program = _s(oem_program)
|
||||
expected_close_date = _s(expected_close_date)
|
||||
notes = _s(notes)
|
||||
pool = await get_pool()
|
||||
|
||||
close_date = None
|
||||
if expected_close_date:
|
||||
try:
|
||||
close_date = datetime.strptime(expected_close_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return json.dumps({"success": False, "message": f"expected_close_date 格式错误:{expected_close_date},应为 YYYY-MM-DD"}, ensure_ascii=False, indent=2)
|
||||
|
||||
if opportunity_id:
|
||||
opp = await pool.fetchrow("SELECT * FROM opportunity WHERE opportunity_id = $1", opportunity_id)
|
||||
if not opp:
|
||||
return json.dumps({"success": False, "message": f"未找到商机 {opportunity_id}"}, ensure_ascii=False, indent=2)
|
||||
|
||||
field_values = {
|
||||
"stage": stage,
|
||||
"expected_amount": expected_amount,
|
||||
"probability": probability,
|
||||
"oem_program": oem_program,
|
||||
"expected_close_date": close_date,
|
||||
"title": title,
|
||||
}
|
||||
updates = []
|
||||
params = []
|
||||
for field, value in field_values.items():
|
||||
if value is not None:
|
||||
updates.append(f"{field} = ${len(params)+1}")
|
||||
params.append(value)
|
||||
|
||||
if updates:
|
||||
updates.append("updated_at = CURRENT_DATE")
|
||||
params.append(opportunity_id)
|
||||
await pool.execute(f"UPDATE opportunity SET {', '.join(updates)} WHERE opportunity_id = ${len(params)}", *params)
|
||||
|
||||
return json.dumps({
|
||||
"success": True, "opportunity_id": opportunity_id, "message": f"商机 {opportunity_id} 已更新"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
if not customer_code or not title:
|
||||
return json.dumps({"success": False, "message": "新建商机需要提供 customer_code 和 title"}, ensure_ascii=False, indent=2)
|
||||
|
||||
customer = await pool.fetchrow("SELECT name FROM customer WHERE customer_code = $1", customer_code)
|
||||
if not customer:
|
||||
return json.dumps({"success": False, "message": f"未找到客户 {customer_code}"}, ensure_ascii=False, indent=2)
|
||||
|
||||
max_seq = await pool.fetchval(
|
||||
"SELECT COALESCE(MAX(CAST(SUBSTRING(opportunity_id FROM '(\\d+)$') AS INTEGER)), 0) FROM opportunity WHERE opportunity_id LIKE 'OPP-2026-%'"
|
||||
)
|
||||
opp_id = f"OPP-2026-{str(max_seq + 1).zfill(4)}"
|
||||
|
||||
await pool.execute(
|
||||
"""INSERT INTO opportunity (opportunity_id, customer_code, title, stage, expected_amount, probability,
|
||||
oem_program, expected_close_date, source, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '智能报价自动生成', 'AI智能报价')""",
|
||||
opp_id, customer_code, title, stage or "lead", expected_amount or 0,
|
||||
probability or 20, oem_program, close_date
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"success": True, "opportunity_id": opp_id, "message": f"商机 {opp_id} 已创建"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 6: get_inquiry_info — 询价单回读(只读)
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def get_inquiry_info(inquiry_id: str) -> str:
|
||||
"""按询价单号回读询价单整行(只读)。用于存档后验证:状态、图号、零件号、年用量、目标价、创建时间。"""
|
||||
# 查询语句处 str 归一化:空串/纯空白同 None
|
||||
inquiry_id = _s(inquiry_id)
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow("SELECT * FROM inquiry WHERE inquiry_id = $1", inquiry_id)
|
||||
if not row:
|
||||
return json.dumps({"success": False, "message": f"未找到询价单 {inquiry_id}"}, ensure_ascii=False, indent=2)
|
||||
return json.dumps({"success": True, "inquiry": dict(row)}, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 7: get_opportunity_info — 商机回读(只读)
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def get_opportunity_info(opportunity_id: str = None, customer_code: str = None) -> str:
|
||||
"""按商机号或客户编码回读商机(只读)。用于存档后验证:stage/probability/expected_amount/更新时间。"""
|
||||
# 查询语句处 str 归一化:空串/纯空白同 None
|
||||
opportunity_id = _s(opportunity_id)
|
||||
customer_code = _s(customer_code)
|
||||
pool = await get_pool()
|
||||
if opportunity_id:
|
||||
row = await pool.fetchrow("SELECT * FROM opportunity WHERE opportunity_id = $1", opportunity_id)
|
||||
rows = [row] if row else []
|
||||
elif customer_code:
|
||||
rows = await pool.fetch("SELECT * FROM opportunity WHERE customer_code = $1 ORDER BY created_at DESC", customer_code)
|
||||
else:
|
||||
return json.dumps({"success": False, "message": "需提供 opportunity_id 或 customer_code"}, ensure_ascii=False, indent=2)
|
||||
return json.dumps({"total": len(rows), "opportunities": [dict(r) for r in rows]}, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 8: get_record_counts — 行数统计(只读)
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def get_record_counts() -> str:
|
||||
"""返回 inquiry/quotation/opportunity 三表行数(只读)。用于数据卫生检查。"""
|
||||
pool = await get_pool()
|
||||
counts = {t: await pool.fetchval(f"SELECT COUNT(*) FROM {t}") for t in ("inquiry", "quotation", "opportunity")}
|
||||
return json.dumps({"success": True, "counts": counts}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 9: purge_test_records — 受限测试数据清理(管理)
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def purge_test_records(
|
||||
quotation_ids: list = None,
|
||||
inquiry_ids: list = None,
|
||||
opportunity_ids: list = None
|
||||
) -> str:
|
||||
"""清理 TC-03 回归测试数据(受限管理工具):仅删除 remarks/notes 含「TC-03回归测试」或 title 以「TC-03」开头的记录,其余一律拒绝,防止误删演示/生产数据。"""
|
||||
pool = await get_pool()
|
||||
deleted = {"quotations": 0, "inquiries": 0, "opportunities": 0, "rejected": []}
|
||||
# 查询语句处 str 归一化:过滤掉 list 里的空串/纯空白 ID(同 None,跳过)
|
||||
qids = [qid for qid in (quotation_ids or []) if _s(qid)]
|
||||
iids = [iid for iid in (inquiry_ids or []) if _s(iid)]
|
||||
oids = [oid for oid in (opportunity_ids or []) if _s(oid)]
|
||||
|
||||
for qid in qids:
|
||||
row = await pool.fetchrow("SELECT remarks FROM quotation WHERE quotation_id = $1", qid)
|
||||
if row and "TC-03回归测试" in (row["remarks"] or ""):
|
||||
await pool.execute("DELETE FROM quotation_cost_detail WHERE quotation_id = $1", qid)
|
||||
await pool.execute("DELETE FROM quotation WHERE quotation_id = $1", qid)
|
||||
deleted["quotations"] += 1
|
||||
else:
|
||||
deleted["rejected"].append(qid)
|
||||
|
||||
for iid in iids:
|
||||
row = await pool.fetchrow("SELECT notes FROM inquiry WHERE inquiry_id = $1", iid)
|
||||
if row and "TC-03回归测试" in (row["notes"] or ""):
|
||||
await pool.execute("DELETE FROM inquiry_item WHERE inquiry_id = $1", iid)
|
||||
await pool.execute("DELETE FROM inquiry WHERE inquiry_id = $1", iid)
|
||||
deleted["inquiries"] += 1
|
||||
else:
|
||||
deleted["rejected"].append(iid)
|
||||
|
||||
for oid in oids:
|
||||
row = await pool.fetchrow("SELECT title FROM opportunity WHERE opportunity_id = $1", oid)
|
||||
if row and (row["title"] or "").startswith("TC-03"):
|
||||
await pool.execute("DELETE FROM opportunity WHERE opportunity_id = $1", oid)
|
||||
deleted["opportunities"] += 1
|
||||
else:
|
||||
deleted["rejected"].append(oid)
|
||||
|
||||
return json.dumps({"success": True, "deleted": deleted}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 10: save_quotation_mock — 报价写入 CRM(Mock 模拟)
|
||||
# ============================================================
|
||||
|
||||
@app.tool()
|
||||
async def save_quotation_mock(
|
||||
part_name: str,
|
||||
unit_price: float,
|
||||
customer_name: str = None,
|
||||
part_number: str = None,
|
||||
material_grade: str = None,
|
||||
mold_cost: float = 0.0,
|
||||
annual_volume: int = None,
|
||||
currency: str = "CNY",
|
||||
remarks: str = None
|
||||
) -> str:
|
||||
"""报价写入 CRM(模拟场景)。写入独立 mock 表 quotation_mock:不校验客户/询价单/商机,与其他工具无关联,仅模拟报价写入 CRM 动作。"""
|
||||
pool = await get_pool()
|
||||
|
||||
max_seq = await pool.fetchval(
|
||||
"SELECT COALESCE(MAX(CAST(SUBSTRING(quotation_id FROM '(\\d+)$') AS INTEGER)), 0) FROM quotation_mock WHERE quotation_id LIKE 'QUO-MOCK-%'"
|
||||
)
|
||||
quotation_id = f"QUO-MOCK-{str(max_seq + 1).zfill(4)}"
|
||||
|
||||
total_annual = round(unit_price * annual_volume, 2) if annual_volume else None
|
||||
|
||||
await pool.execute(
|
||||
"""INSERT INTO quotation_mock (quotation_id, customer_name, part_name, part_number, material_grade,
|
||||
mold_cost, unit_price, annual_volume, total_annual, currency, status, remarks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'draft', $11)""",
|
||||
quotation_id, customer_name, part_name, part_number, material_grade,
|
||||
mold_cost, unit_price, annual_volume, total_annual, currency, remarks
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"quotation_id": quotation_id,
|
||||
"status": "draft",
|
||||
"summary": {
|
||||
"part_name": part_name, "unit_price": unit_price, "mold_cost": mold_cost,
|
||||
"annual_volume": annual_volume, "total_annual": total_annual, "currency": currency
|
||||
},
|
||||
"message": f"报价单 {quotation_id} 已写入 CRM mock 表(quotation_mock)"
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 启动 HTTP Server
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(app.run_streamable_http_async(host="0.0.0.0", port=8002, streamable_http_path="/mcp"))
|
||||
@@ -1,278 +0,0 @@
|
||||
# MCP for CRM — 数据表结构与种子数据说明文档
|
||||
|
||||
> 数据库:PostgreSQL 15+,库 `smart_quotation_auto`(与 ERP 服务共库不同表)
|
||||
> 建表/种子脚本:`sql/init.sql`(v3,幂等可重跑);执行入口:`cd src && python3 seed.py`
|
||||
> 版本:v1.0(2026-08-09)
|
||||
|
||||
---
|
||||
|
||||
## 1 概览
|
||||
|
||||
| # | 表名 | 用途 | 主键 | 种子行数 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | `customer` | 客户主数据(信用/折扣/账期) | customer_code | 20 |
|
||||
| 2 | `inquiry` | 询价单 | inquiry_id | 9 |
|
||||
| 3 | `inquiry_item` | 询价明细行(多零件) | id(SERIAL) | 9 |
|
||||
| 4 | `quotation` | 报价单(多版本链) | quotation_id | 7 |
|
||||
| 5 | `quotation_cost_detail` | 报价成本明细 | id(SERIAL) | 7 |
|
||||
| 6 | `opportunity` | 商机 | opportunity_id | 8 |
|
||||
| 7 | `quotation_mock` | 报价写入 CRM 模拟(独立 mock 表) | quotation_id | 0(运行时写入) |
|
||||
|
||||
**表关系**:
|
||||
|
||||
```
|
||||
customer ──┬── inquiry ──── inquiry_item (客户 1─N 询价;询价 1─N 明细行)
|
||||
├── quotation ── quotation_cost_detail(客户 1─N 报价;报价 1─1 成本明细)
|
||||
│ └── inquiry_id 关联询价(版本链:同询价 N 版报价)
|
||||
└── opportunity (客户 1─N 商机)
|
||||
```
|
||||
|
||||
**外键**:inquiry/inquiry_item/quotation/opportunity 均外键指向 `customer.customer_code`;inquiry_item → inquiry;quotation → inquiry(可空);quotation_cost_detail → quotation。`quotation_mock` 无任何外键,为独立模拟表。
|
||||
|
||||
---
|
||||
|
||||
## 2 表结构详解
|
||||
|
||||
### 2.1 customer — 客户
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `customer_code` | VARCHAR(50) | PRIMARY KEY | 客户编码,如 `OEM-2024-003` |
|
||||
| `name` | VARCHAR(200) | NOT NULL | 客户名称 |
|
||||
| `oem_tier` | VARCHAR(20) | NOT NULL | OEM / Tier1 / Tier2 |
|
||||
| `credit_level` | VARCHAR(5) | DEFAULT 'B' | 信用等级:A/B/C/D/无评级 |
|
||||
| `discount_rate` | DECIMAL(4,3) | DEFAULT 0 | 折扣率(0.040 = 4%) |
|
||||
| `payment_days` | INTEGER | DEFAULT 30 | 账期天数(0 = 货到付款/预付) |
|
||||
| `contact_person` / `phone` / `email` | VARCHAR | — | 联系人信息 |
|
||||
| `industry` / `region` | VARCHAR(50) | — | 行业 / 区域 |
|
||||
| `total_orders` | INTEGER | DEFAULT 0 | 历史订单数 |
|
||||
| `total_amount` | DECIMAL(14,2) | DEFAULT 0 | 历史订单总额 |
|
||||
| `notes` | TEXT | — | 备注(风险提示等) |
|
||||
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 建档时间 |
|
||||
|
||||
### 2.2 inquiry — 询价单
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `inquiry_id` | VARCHAR(50) | PRIMARY KEY | 询价单号 `INQ-YYYY-NNNN` |
|
||||
| `customer_code` | VARCHAR(50) | NOT NULL,FK | 客户编码 |
|
||||
| `inquiry_date` | DATE | NOT NULL DEFAULT CURRENT_DATE | 询价日期 |
|
||||
| `drawing_number` | VARCHAR(100) | — | 图纸号 |
|
||||
| `part_number` | VARCHAR(100) | — | 零件号 |
|
||||
| `annual_volume` | INTEGER | — | 年用量(件/年) |
|
||||
| `target_price` | DECIMAL(10,2) | — | 客户目标价 ¥ |
|
||||
| `status` | VARCHAR(20) | DEFAULT 'pending' | pending / quoted(首报自动推进);种子含历史态 completed/processing/lost |
|
||||
| `notes` | TEXT | — | 备注 |
|
||||
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
||||
|
||||
### 2.3 inquiry_item — 询价明细行
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `id` | SERIAL | PRIMARY KEY | 自增主键 |
|
||||
| `inquiry_id` | VARCHAR(50) | NOT NULL,FK | 所属询价单 |
|
||||
| `line_no` | INTEGER | NOT NULL | 行号;UNIQUE(inquiry_id, line_no) |
|
||||
| `part_name` | VARCHAR(200) | — | 零件名称 |
|
||||
| `part_number` | VARCHAR(100) | — | 零件号 |
|
||||
| `material_grade` | VARCHAR(50) | — | 客户材质牌号 |
|
||||
| `volume_cc` | DECIMAL(10,2) | — | 体积 cm³ |
|
||||
| `annual_qty` | INTEGER | — | 该行年用量 |
|
||||
| `unit` | VARCHAR(10) | DEFAULT '件' | 单位 |
|
||||
| `remarks` | TEXT | — | 备注 |
|
||||
|
||||
### 2.4 quotation — 报价单(多版本核心表)
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `quotation_id` | VARCHAR(50) | PRIMARY KEY | 报价单号 `QUO-YYYY-NNNN` |
|
||||
| `inquiry_id` | VARCHAR(50) | FK(可空) | 所属询价单;索引 idx_quotation_inquiry |
|
||||
| `customer_code` | VARCHAR(50) | NOT NULL,FK | 客户编码 |
|
||||
| `quotation_date` | DATE | NOT NULL DEFAULT CURRENT_DATE | 报价日期 |
|
||||
| `valid_until` | DATE | — | 有效期至 |
|
||||
| `version` | INTEGER | DEFAULT 1 | 版本号;**UNIQUE(inquiry_id, version)**(并发防重) |
|
||||
| `status` | VARCHAR(20) | DEFAULT 'draft' | draft/sent/approved/superseded/won/lost |
|
||||
| `mold_cost` | DECIMAL(12,2) | DEFAULT 0 | 模具费(一次性) |
|
||||
| `unit_price` | DECIMAL(10,2) | — | 单价 ¥ |
|
||||
| `annual_volume` | INTEGER | — | 年用量 |
|
||||
| `total_annual` | DECIMAL(14,2) | — | 年总额 = 单价×年用量 |
|
||||
| `subtotal` | DECIMAL(14,2) | — | 小计 |
|
||||
| `tax_rate` | DECIMAL(4,3) | DEFAULT 0.130 | 税率 |
|
||||
| `tax_amount` | DECIMAL(14,2) | — | 税额 |
|
||||
| `total_amount` | DECIMAL(14,2) | — | 含税总额 |
|
||||
| `currency` | VARCHAR(10) | DEFAULT 'CNY' | 币种 |
|
||||
| `payment_terms` | VARCHAR(200) | DEFAULT '月结30天' | 付款条款 |
|
||||
| `delivery_terms` | VARCHAR(200) | DEFAULT '含税含运' | 交付条款 |
|
||||
| `created_by` | VARCHAR(50) | DEFAULT 'AI智能报价' | 创建者 |
|
||||
| `approved_by` | VARCHAR(50) | — | 审核人(人工审核后填写) |
|
||||
| `remarks` | TEXT | — | 备注(测试数据防护标记写入处) |
|
||||
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
||||
|
||||
**版本链规则**:save_quotation 事务内 `version = MAX(version)+1`(不限次数);旧版非终态批量置 `superseded`;`won`/`lost` 终态保留不覆盖。
|
||||
|
||||
### 2.5 quotation_cost_detail — 报价成本明细
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `id` | SERIAL | PRIMARY KEY | 自增主键 |
|
||||
| `quotation_id` | VARCHAR(50) | NOT NULL,FK,UNIQUE(uq_quotation_cost) | 报价单号(1:1) |
|
||||
| `material_cost` | DECIMAL(10,4) | — | 材料成本 ¥/件 |
|
||||
| `casting_cost` | DECIMAL(10,4) | — | 压铸成本 ¥/件 |
|
||||
| `machining_cost` | DECIMAL(10,4) | — | 机加工成本 ¥/件 |
|
||||
| `post_process_cost` | DECIMAL(10,4) | — | 后处理成本 ¥/件 |
|
||||
| `overhead_rate` | DECIMAL(4,3) | — | 管理费率 |
|
||||
| `profit_rate` | DECIMAL(4,3) | — | 利润率 |
|
||||
| `total_cost` | DECIMAL(10,4) | — | 四项成本小计 |
|
||||
| `unit_price` | DECIMAL(10,4) | — | 单价(冗余留痕,与主表对账) |
|
||||
|
||||
### 2.6 opportunity — 商机
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `opportunity_id` | VARCHAR(50) | PRIMARY KEY | 商机号 `OPP-YYYY-NNNN` |
|
||||
| `customer_code` | VARCHAR(50) | NOT NULL,FK | 客户编码 |
|
||||
| `title` | VARCHAR(200) | NOT NULL | 商机标题(测试数据防护标记写入处) |
|
||||
| `stage` | VARCHAR(20) | DEFAULT 'lead' | lead/qualified/proposal/negotiation/quoted/won/lost |
|
||||
| `expected_amount` | DECIMAL(14,2) | DEFAULT 0 | 预期金额 ¥ |
|
||||
| `probability` | INTEGER | DEFAULT 20 | 成交概率 % |
|
||||
| `oem_program` | VARCHAR(200) | — | OEM 项目/平台 |
|
||||
| `expected_close_date` | DATE | — | 预期关单日期 |
|
||||
| `source` | VARCHAR(100) | — | 来源(智能报价自动生成/客户主动询价/展会获客等) |
|
||||
| `created_by` | VARCHAR(50) | — | 创建者 |
|
||||
| `created_at` / `updated_at` | DATE | DEFAULT CURRENT_DATE | 创建/更新日期 |
|
||||
|
||||
---
|
||||
|
||||
### 2.7 quotation_mock — 报价写入 CRM 模拟表(独立,无外键)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| quotation_id | VARCHAR(50) PK | 模拟报价单号,`QUO-MOCK-####` 自增 |
|
||||
| customer_name | VARCHAR(200) | 客户名称(自由文本,不校验) |
|
||||
| part_name | VARCHAR(200) | 零件名称 |
|
||||
| part_number | VARCHAR(50) | 零件号 |
|
||||
| material_grade | VARCHAR(50) | 材质牌号 |
|
||||
| mold_cost | DECIMAL(12,2) | 模具费,默认 0 |
|
||||
| unit_price | DECIMAL(10,2) | 报价单价 |
|
||||
| annual_volume | INTEGER | 年用量 |
|
||||
| total_annual | DECIMAL(14,2) | 年总额(unit_price × annual_volume) |
|
||||
| currency | VARCHAR(10) | 币种,默认 CNY |
|
||||
| quotation_date | DATE | 报价日期,默认当天 |
|
||||
| status | VARCHAR(20) | 状态,固定 `draft` |
|
||||
| remarks | TEXT | 备注 |
|
||||
| created_at | TIMESTAMP | 创建时间 |
|
||||
|
||||
> 由工具 `save_quotation_mock` 单独写入,与正式报价链路(inquiry/quotation/opportunity)完全隔离,用于模拟「报价写入 CRM」场景。
|
||||
|
||||
## 3 种子数据详解
|
||||
|
||||
### 3.1 customer — 20 家客户
|
||||
|
||||
覆盖设计:
|
||||
|
||||
- **层级**:OEM×7 / Tier1×7 / Tier2×6
|
||||
- **信用梯度**:A×8 / B×6 / C×2 / D×1 / 无评级×3
|
||||
- **特殊样本**:零历史新客户×3(T2-2025-002、T2-2026-001、T2-2026-002,用于信用评审场景);长账期 90 天(OEM-2025-001);货到付款 C/D 级(T2-2025-001/003/004)
|
||||
|
||||
| 编码 | 名称 | 层级 | 信用 | 折扣率 | 账期 | 区域 | 特征 |
|
||||
|------|------|------|------|--------|------|------|------|
|
||||
| OEM-2024-001 | 某日系主机厂 | OEM | A | 5.0% | 60 | 上海 | 长期合作,铝合金阀体壳 |
|
||||
| OEM-2024-002 | 某德系主机厂 | OEM | A | 3.0% | 45 | 北京 | 质量要求高,偏好铸铁件 |
|
||||
| OEM-2024-003 | 某新能源主机厂 | OEM | A | 4.0% | 60 | 合肥 | 三电配套,增长快 |
|
||||
| OEM-2024-004 | 某美系主机厂 | OEM | A | 3.0% | 45 | 武汉 | 混动变速箱项目 |
|
||||
| OEM-2025-001 | 某商用车主机厂 | OEM | B | 2.0% | 90 | 长春 | 长账期,注意现金流 |
|
||||
| OEM-2025-002 | 某乘商两用主机厂 | OEM | B | 2.5% | 60 | 柳州 | 乘商双线采购 |
|
||||
| OEM-2026-001 | 某智能电动新势力 | OEM | A | 5.0% | 45 | 上海 | 高折扣,战略培育 |
|
||||
| T1-2025-001 | 某博世供应商 | Tier1 | B | 2.0% | 30 | 江苏苏州 | 变速箱配套 |
|
||||
| T1-2025-002 | 某大陆供应商 | Tier1 | B | 0 | 30 | 广东广州 | 制动配套,新开发 |
|
||||
| T1-2025-003 | 某采埃孚供应商 | Tier1 | A | 2.0% | 45 | 上海 | ZF 悬架配套 |
|
||||
| T1-2025-004 | 某电装系供应商 | Tier1 | A | 2.5% | 45 | 广州 | 日系热管理 |
|
||||
| T1-2025-005 | 某日立安斯泰莫供应商 | Tier1 | A | 2.0% | 45 | 大连 | 日系制动/转向 |
|
||||
| T1-2026-001 | 某制动系统供应商 | Tier1 | B | 1.5% | 30 | 重庆 | 国产线控制动 |
|
||||
| T1-2026-002 | 某液压系统供应商 | Tier1 | B | 1.0% | 60 | 长沙 | 液压阀体,账期长 |
|
||||
| T2-2025-001 | 某售后市场贸易商 | Tier2 | C | 0 | 0 | 杭州 | 货到付款,注意风险 |
|
||||
| T2-2025-002 | 某智能底盘初创公司 | Tier2 | 无评级 | 0 | 30 | 苏州 | 零订单新客户 |
|
||||
| T2-2025-003 | 某非道路机械贸易商 | Tier2 | D | 0 | 0 | 济宁 | 仅接受预付款 |
|
||||
| T2-2025-004 | 某改装件连锁商 | Tier2 | C | 0 | 0 | 成都 | 改装连锁,货到付款 |
|
||||
| T2-2026-001 | 某机器人关节初创公司 | Tier2 | 无评级 | 0 | 30 | 深圳 | 人形机器人关节部件 |
|
||||
| T2-2026-002 | 某低空经济无人机公司 | Tier2 | 无评级 | 0 | 30 | 深圳 | eVTOL 液压部件 |
|
||||
|
||||
### 3.2 inquiry + inquiry_item — 9 单询价(含明细行)
|
||||
|
||||
覆盖设计:2024~2025 历史闭环(completed/lost)+ 2026 在途(pending/processing);单零件行,多零件场景由运行时写入。
|
||||
|
||||
| 询价单 | 客户 | 日期 | 零件 | 年用量 | 目标价 | 状态 |
|
||||
|--------|------|------|------|--------|--------|------|
|
||||
| INQ-2024-9005 | OEM-2024-001 | 2024-10-20 | 发动机油路阀体壳(EN AC-4600,555.56cc) | 200,000 | 55.00 | completed |
|
||||
| INQ-2025-9001 | OEM-2024-001 | 2025-09-25 | 变速箱阀体外壳(ADC12,444.44cc) | 300,000 | 96.00 | completed |
|
||||
| INQ-2025-9002 | OEM-2024-002 | 2025-08-10 | 制动阀体壳(HT250,388.89cc) | 150,000 | 155.00 | completed |
|
||||
| INQ-2025-9003 | T1-2025-001 | 2025-05-28 | 转向器阀体壳(A356-T6,296.30cc) | 150,000 | 25.00 | lost |
|
||||
| INQ-2025-9006 | OEM-2024-002 | 2025-11-05 | 变速箱阀体外壳(ADC12,小批量) | 100,000 | 100.00 | completed |
|
||||
| INQ-2026-0001 | OEM-2024-001 | 2026-08-01 | 变速箱阀体外壳(ADC12,444.44cc) | 300,000 | 95.00 | processing |
|
||||
| INQ-2026-0002 | T1-2025-001 | 2026-08-03 | 转向器阀体壳(A356-T6,296.30cc) | 150,000 | 55.00 | pending |
|
||||
| INQ-2026-0006 | OEM-2024-004 | 2026-08-05 | 涡轮增压控制阀体壳(ADC12,320cc) | 120,000 | 48.00 | pending |
|
||||
| INQ-2026-0007 | OEM-2025-001 | 2026-08-06 | 重卡制动阀体壳(HT300,850cc,1000T) | 60,000 | 260.00 | processing |
|
||||
|
||||
### 3.3 quotation + quotation_cost_detail — 7 张报价
|
||||
|
||||
覆盖设计:状态谱系 approved/won/lost/sent + 一条**两版议价链**(INQ-2025-9001:v1→v2,一致性校验基准)+ 跨客户价差样本。
|
||||
|
||||
| 报价单 | 询价单 | 客户 | 版本 | 状态 | 模具费 | 单价 | 年用量 | 含税总额 | 备注 |
|
||||
|--------|--------|------|------|------|--------|------|--------|----------|------|
|
||||
| QUO-2024-9005 | INQ-2024-9005 | OEM-2024-001 | 1 | approved | 180,000 | 52.30 | 200,000 | 11,819,800 | 2024 基线价 |
|
||||
| QUO-2025-9001 | INQ-2025-9001 | OEM-2024-001 | 1 | approved | 0 | 42.50 | 300,000 | 14,407,500 | 模具费已摊销 |
|
||||
| QUO-2025-9002 | INQ-2025-9001 | OEM-2024-001 | 2 | approved | 0 | 41.90 | 300,000 | 14,204,100 | 第二轮降价 |
|
||||
| QUO-2025-9003 | INQ-2025-9002 | OEM-2024-002 | 1 | won | 250,000 | 46.80 | 150,000 | 7,932,600 | 已中标(终态保护样本) |
|
||||
| QUO-2025-9004 | INQ-2025-9003 | T1-2025-001 | 1 | lost | 120,000 | 28.40 | 150,000 | 4,813,800 | 报价偏高丢单(利润率 28% 样本) |
|
||||
| QUO-2025-9006 | INQ-2025-9006 | OEM-2024-002 | 1 | sent | 0 | 43.20 | 100,000 | 4,881,600 | 跨客户价差校验 |
|
||||
| QUO-2026-0001 | INQ-2026-0001 | OEM-2024-001 | 1 | sent | 180,000 | 92.50 | 300,000 | 31,357,500 | 老客户优惠,含模具费 |
|
||||
|
||||
成本明细与报价 1:1(四项成本 + 12% 管理费 + 15% 利润,仅 QUO-2025-9004 为 28% 利润)。
|
||||
|
||||
### 3.4 opportunity — 8 条商机
|
||||
|
||||
覆盖设计:阶段全谱系 lead/qualified/proposal/negotiation/won/lost;金额 450 万~2775 万;AI 与销售团队双来源。
|
||||
|
||||
| 商机号 | 客户 | 阶段 | 金额¥ | 概率 | 关单日期 | OEM 项目 |
|
||||
|--------|------|------|-------|------|----------|----------|
|
||||
| OPP-2025-0005 | OEM-2024-002 | won | 8,200,000 | 100% | 2025-09-30 | 德系新制动平台 |
|
||||
| OPP-2025-0006 | T1-2025-001 | lost | 4,500,000 | 0% | 2025-07-31 | EPS 转向系统 |
|
||||
| OPP-2026-0001 | OEM-2024-001 | proposal | 27,750,000 | 60% | 2026-10-31 | 日系新车型 CVT |
|
||||
| OPP-2026-0002 | T1-2025-001 | lead | 8,250,000 | 30% | 2026-12-31 | EPS 转向配套 |
|
||||
| OPP-2026-0003 | T1-2025-003 | lead | 5,000,000 | 20% | 2026-12-31 | 主动悬架配套 |
|
||||
| OPP-2026-0004 | OEM-2024-003 | negotiation | 9,000,000 | 60% | 2026-11-30 | 纯电平台热管理+制动 |
|
||||
| OPP-2026-0005 | OEM-2024-004 | qualified | 12,000,000 | 40% | 2027-03-31 | 下一代混动变速箱 |
|
||||
| OPP-2026-0006 | OEM-2025-001 | lead | 6,000,000 | 15% | 2026-12-31 | 重卡制动升级 |
|
||||
|
||||
---
|
||||
|
||||
## 4 运行时数据(种子之外,真实运行留痕)
|
||||
|
||||
以下数据由智能体真实运行产生,**不属于 init.sql**,作为演示/生产留痕保留:
|
||||
|
||||
- **版本链演示**:INQ-2026-0019 三版议价(v1→v2→v3,旧版 superseded,带 price_change_pct)。
|
||||
- **生产报价留痕**:INQ-2026-0020~0027、QUO-2026-0019~0027、OPP-2026-0012~0018(含 TC-01/02/05 生产运行,如 QUO-2026-0027 单价 ¥8.06)。
|
||||
- **数据库现状**(2026-08-09):customer 20 / inquiry 24 / quotation 27 / opportunity 18;inquiry_item 与成本明细随行数联动。
|
||||
- **回归测试数据**:TC-03 写入带「TC-03回归测试」标记,跑完经 `purge_test_records` 自清理,不累积。
|
||||
|
||||
---
|
||||
|
||||
## 5 幂等性与重跑机制
|
||||
|
||||
`init.sql` 整体可重复执行,无副作用(NF-3):
|
||||
|
||||
1. **建表**:`CREATE TABLE IF NOT EXISTS`。
|
||||
2. **历史去重**:inquiry_item 按 (inquiry_id, line_no)、quotation_cost_detail 按 quotation_id 删重(保留最小 id),随后幂等补唯一约束。
|
||||
3. **版本号规范化**:对存量 quotation 按 (inquiry_id, created_at, quotation_id) 重写 version 为 1..n(幂等,已规范的行不变)。
|
||||
4. **并发防重**:`UNIQUE(inquiry_id, version)` + 索引 `idx_quotation_inquiry`。
|
||||
5. **种子写入**:全部 `ON CONFLICT (...) DO NOTHING`,重跑不产生重复客户/询价/报价/商机。
|
||||
|
||||
---
|
||||
|
||||
## 6 数据维护约定
|
||||
|
||||
| 场景 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| 新客户建档 | 直接 SQL 或后续扩展工具 | customer_code 规则:OEM/T1/T2-年份-序号 |
|
||||
| 报价审核 | UPDATE quotation SET status/approved_by | 当前无 MCP 写工具,人工线下完成 |
|
||||
| 测试数据清理 | 仅经 MCP `purge_test_records` | 禁止直连 DELETE;非 TC-03 标记数据受防护 |
|
||||
| 生产数据 | 不得清理 | 演示/运行留痕作为验收基线 |
|
||||
@@ -1,383 +0,0 @@
|
||||
# MCP for ERP — 工具说明文档
|
||||
|
||||
> 服务对象:汽车零部件(小型阀体外壳)智能报价智能体
|
||||
> 服务定位:ERP 侧核价基础数据与成本计算引擎(只读 + 计算,不写库)
|
||||
> 版本:v1.1(2026-08-09)
|
||||
|
||||
---
|
||||
|
||||
## 1 服务概览
|
||||
|
||||
| 项 | 值 |
|
||||
|----|----|
|
||||
| 服务名 | `mcp-for-erp-auto` |
|
||||
| 框架 | MCPServer(mcp 2.0.0 内置高级框架),`@app.tool()` 装饰器注册 |
|
||||
| 传输协议 | Streamable HTTP(JSON-RPC 2.0) |
|
||||
| 远程端点 | `https://erp-data.dongsk.top/mcp` |
|
||||
| 本地端点 | `http://0.0.0.0:8001/mcp`(`cd src && python3 server.py`) |
|
||||
| 数据库 | PostgreSQL 15+,库 `smart_quotation_auto`(仅服务层内部经 `db.py` 连接池访问) |
|
||||
| 工具数 | **5 个**(全部只读/计算) |
|
||||
|
||||
### 工具一览
|
||||
|
||||
| # | 工具 | 功能 | 报价环节 |
|
||||
|---|------|------|----------|
|
||||
| 1 | `query_material_master` | 查询物料主数据(体积/密度/吨位/后处理) | 基础数据 |
|
||||
| 2 | `match_material_grade` | 客户牌号 → 内部等效牌号对照 | 材质确认 |
|
||||
| 3 | `get_die_casting_params` | 压铸参数:铝水重量、节拍、铸造成本 | 压铸成本 |
|
||||
| 4 | `get_machining_estimate` | 机加工工时估算(固化工装节拍) | 机加工成本 |
|
||||
| 5 | `calculate_part_cost` | 汇总核价,输出最终单价 | 最终报价 |
|
||||
|
||||
---
|
||||
|
||||
## 2 调用约定
|
||||
|
||||
### 2.1 客户端注册(mcpServers)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"erp": { "url": "https://erp-data.dongsk.top/mcp" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 JSON-RPC 调用流程
|
||||
|
||||
1. `initialize`(握手,获取 `Mcp-Session-Id`)
|
||||
2. `notifications/initialized`(通知,无响应体)
|
||||
3. `tools/list`(可选,列出工具)
|
||||
4. `tools/call`(调用工具,`params: {name, arguments}`)
|
||||
|
||||
### 2.3 兜底命令行客户端(纯标准库)
|
||||
|
||||
```bash
|
||||
python3 scripts/mcp_http_client.py erp <工具名> '<JSON参数>'
|
||||
# 示例
|
||||
python3 scripts/mcp_http_client.py erp calculate_part_cost '{"material_code":"VHB-AT-001","annual_qty":300000}'
|
||||
```
|
||||
|
||||
### 2.4 返回通用约定
|
||||
|
||||
- 所有工具返回 **JSON 字符串**(`ensure_ascii=False`,中文不转义)。
|
||||
- 查询类工具返回 `{"total": N, "<集合名>": [...]}`;操作/计算类工具返回 `{"success": true|false, ...}`。
|
||||
- 失败统一形态:`{"success": false, "message": "<原因>"}`(查询类失败以 `total: 0` 空集表达)。
|
||||
- 金额/重量:浮点数,成本保留 2 位小数,重量保留 3 位小数;日期 `YYYY-MM-DD`,月份 `YYYY-MM`。
|
||||
|
||||
---
|
||||
|
||||
## 3 工具详解
|
||||
|
||||
### 3.1 query_material_master — 查询物料主数据
|
||||
|
||||
**功能**:按物料编码精确查询,或按零件类别、材质牌号、压铸吨位组合过滤。返回物料档案(含体积、密度、净重、吨位、表面处理),是核价的起点数据。
|
||||
|
||||
**入参**(全部可选;全部不传 = 返回全表):
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `material_code` | string | 否 | — | 物料编码,精确匹配,如 `VHB-AT-001` |
|
||||
| `part_category` | string | 否 | — | 零件类别,精确匹配,如 `阀体外壳` |
|
||||
| `material_grade` | string | 否 | — | 客户材质牌号,精确匹配,如 `ADC12` |
|
||||
| `die_casting_ton` | int | 否 | — | 压铸机吨位,精确匹配,如 `400` |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total` | int | 命中条数 |
|
||||
| `materials[]` | array | 物料行列表 |
|
||||
| `materials[].material_code` | string | 物料编码(内部主键) |
|
||||
| `materials[].part_name` | string | 零件名称 |
|
||||
| `materials[].part_category` | string | 零件类别 |
|
||||
| `materials[].material_grade` | string | 客户材质牌号 |
|
||||
| `materials[].density` | decimal | 密度 g/cm³(JSON 中为字符串) |
|
||||
| `materials[].volume_cc` | decimal | 零件体积 cm³(算铝水重量的依据) |
|
||||
| `materials[].net_weight_kg` | decimal | 净重 kg(算后处理成本的依据) |
|
||||
| `materials[].die_casting_ton` | int | 压铸机吨位 |
|
||||
| `materials[].surface_treatment` | string | 表面处理(阳极氧化/钝化/无等) |
|
||||
| `materials[].unit` | string | 计量单位 |
|
||||
| `materials[].created_at` | string | 建档时间 |
|
||||
|
||||
**返回示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"materials": [
|
||||
{
|
||||
"material_code": "VHB-AT-001",
|
||||
"part_name": "变速箱阀体外壳",
|
||||
"part_category": "阀体外壳",
|
||||
"material_grade": "ADC12",
|
||||
"density": "2.700",
|
||||
"volume_cc": "444.44",
|
||||
"net_weight_kg": "1.200",
|
||||
"die_casting_ton": 400,
|
||||
"surface_treatment": "阳极氧化",
|
||||
"unit": "件",
|
||||
"created_at": "2026-08-07 12:18:51.484993"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 match_material_grade — 材质牌号对照
|
||||
|
||||
**功能**:把客户指定牌号(GB/ASTM/JIS/EN/ISO 任一体系或内部码)对照为企业内部等效牌号,返回密度与铝锭参考价。支持模糊匹配与热处理后缀自动剥离(如 `A356-T6` → `A356`,最多剥 2 段)。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `customer_grade` | string | **是** | — | 客户牌号,大小写不敏感,模糊匹配六大码系 |
|
||||
| `standard` | string | 否 | — | 标准体系提示(当前实现未参与过滤,保留参数) |
|
||||
|
||||
**返回字段**(命中时):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `matched` | bool | 是否命中 |
|
||||
| `customer_grade` | string | 原客户牌号 |
|
||||
| `internal_code` | string | 内部等效牌号,如 `AL-ADC12` |
|
||||
| `density` | float | 密度 g/cm³ |
|
||||
| `price_per_kg` | float | 铝锭参考价 ¥/kg(兜底价,核价优先取月度价) |
|
||||
| `all_mappings[]` | array | 全部命中行(含 gb/astm/jis/en/iso/internal 六码、grade_group 材质族) |
|
||||
|
||||
**未命中返回**:
|
||||
|
||||
```json
|
||||
{ "matched": false, "message": "未找到材质 AD12" }
|
||||
```
|
||||
|
||||
**返回示例**(`ADC12`):
|
||||
|
||||
```json
|
||||
{
|
||||
"matched": true,
|
||||
"customer_grade": "ADC12",
|
||||
"internal_code": "AL-ADC12",
|
||||
"density": 2.7,
|
||||
"price_per_kg": 18.5,
|
||||
"all_mappings": [
|
||||
{
|
||||
"grade_group": "铝合金",
|
||||
"gb_code": "ZAlSi10Cu(ADC12)",
|
||||
"astm_code": "A380",
|
||||
"jis_code": "ADC12",
|
||||
"en_code": "EN AC-4600",
|
||||
"iso_code": "AlSi9Cu3",
|
||||
"internal_code": "AL-ADC12",
|
||||
"density": "2.700",
|
||||
"price_per_kg": "18.50"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**业务规则**:未命中时 Skill 层应回退默认铝价并产生 warning(TC-06 场景),不应中断报价。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 get_die_casting_params — 获取压铸参数
|
||||
|
||||
**功能**:按物料编码取压铸参数。核心输出:铝水重量(体积×密度×1.05 损耗)、压铸机吨位、模次节拍、单件压铸成本。体现 MVP 原则「**靠体积算铝水重量、压铸机吨位固定工时**」。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `material_code` | string | **是** | — | 物料编码 |
|
||||
| `volume_cc` | float | 否 | 物料档案体积 | 覆盖体积(图纸实测体积优先于档案默认值) |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success` | bool | 成功标记 |
|
||||
| `material_code` | string | 物料编码 |
|
||||
| `volume_cc` | float | 采用体积 cm³ |
|
||||
| `density` | float | 密度 g/cm³ |
|
||||
| `aluminum_weight_kg` | float | 铝水重量 = 体积×密度/1000×1.05,3 位小数 |
|
||||
| `die_casting_ton` | int | 压铸机吨位 |
|
||||
| `cycle_time_sec` | float | 模次节拍(秒/模,按吨位固化) |
|
||||
| `machine_rate` | float | 机台费率 ¥/小时 |
|
||||
| `mold_cavities` | int | 模穴数 |
|
||||
| `casting_cost_per_part` | float | 单件压铸成本 = (节拍/3600)×费率/模穴数,2 位小数 |
|
||||
|
||||
**失败返回**:`{"success": false, "message": "未找到物料 XXX"}` 或 `{"success": false, "message": "未找到 400T 压铸机参数"}`
|
||||
|
||||
**返回示例**(VHB-AT-001,444.44cc,400T):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"material_code": "VHB-AT-001",
|
||||
"volume_cc": 444.44,
|
||||
"density": 2.7,
|
||||
"aluminum_weight_kg": 1.26,
|
||||
"die_casting_ton": 400,
|
||||
"cycle_time_sec": 30.0,
|
||||
"machine_rate": 260.0,
|
||||
"mold_cavities": 1,
|
||||
"casting_cost_per_part": 2.17
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 get_machining_estimate — 机加工工时估算
|
||||
|
||||
**功能**:按孔清单(钻孔/攻丝/铰孔)与铣面数量估算机加工总工时与刀具成本。所有规格节拍来自固化表 `machining_cycle`(工装节拍),逐项累加。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `material_code` | string | **是** | — | 物料编码(回显用) |
|
||||
| `drill_holes` | array[object] | 否 | [] | 钻孔清单,元素 `{"spec": "Φ6", "count": 2}` |
|
||||
| `tap_holes` | array[object] | 否 | [] | 攻丝清单,元素 `{"spec": "M6×1.0", "count": 2}` |
|
||||
| `ream_holes` | array[object] | 否 | [] | 铰孔清单,元素 `{"spec": "Φ8H7", "count": 1}` |
|
||||
| `mill_faces` | int | 否 | 0 | 铣面数量 |
|
||||
|
||||
孔元素字段:`spec`(规格字符串,缺省 Φ8 / M8×1.25 / Φ8H7)、`count`(数量,缺省 1)。
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success` | bool | 成功标记 |
|
||||
| `material_code` | string | 物料编码 |
|
||||
| `total_time_sec` | float | 总工时(秒),2 位小数 |
|
||||
| `total_time_hours` | float | 总工时(小时),4 位小数 |
|
||||
| `total_tool_cost` | float | 刀具成本合计 ¥ |
|
||||
| `details[]` | array | 逐项明细:`operation`(drill/tap/ream/mill)、`spec`、`count`、`total_time_sec` |
|
||||
|
||||
**返回示例**(钻 Φ6×2 + 攻 M6×1.0×2 + 铣面×1):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"material_code": "VHB-AT-001",
|
||||
"total_time_sec": 26.0,
|
||||
"total_time_hours": 0.0072,
|
||||
"total_tool_cost": 1.5,
|
||||
"details": [
|
||||
{ "operation": "drill", "spec": "Φ6", "count": 2, "total_time_sec": 6.0 },
|
||||
{ "operation": "tap", "spec": "M6×1.0", "count": 2, "total_time_sec": 8.0 },
|
||||
{ "operation": "mill", "spec": "face", "count": 1, "total_time_sec": 12.0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:固化表中不存在的规格**静默跳过**(不计工时也不报错);毛坯件(无孔无面)返回总工时 0,Skill 层应产生 warning(TC-07 场景)。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 calculate_part_cost — 汇总核价(最终单价)
|
||||
|
||||
**功能**:一站式核价。材料成本(铝水重量×当月铝价)+ 压铸成本 + 机加工成本 + 后处理成本 → 小计 × (1+管理费率) × (1+利润率) = 最终单价。铝价优先取 `material_price_history` 最新月度价并留痕月份;机加工费率按材质族(铝合金 60 / 灰铁 75 / 球铁 80 ¥/h)取 `machining_rate`。
|
||||
|
||||
**入参**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `material_code` | string | **是** | — | 物料编码 |
|
||||
| `annual_qty` | int | **是** | — | 年用量(件/年) |
|
||||
| `drill_holes` | array[object] | 否 | [] | 同 3.4 |
|
||||
| `tap_holes` | array[object] | 否 | [] | 同 3.4 |
|
||||
| `ream_holes` | array[object] | 否 | [] | 同 3.4 |
|
||||
| `mill_faces` | int | 否 | 0 | 铣面数量 |
|
||||
| `overhead_rate` | float | 否 | 0.12 | 管理费率 |
|
||||
| `profit_rate` | float | 否 | 0.15 | 利润率 |
|
||||
|
||||
**返回字段**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `success` | bool | 成功标记 |
|
||||
| `material_code` / `part_name` | string | 物料编码 / 零件名称 |
|
||||
| `cost_breakdown.material_cost` | float | 材料成本 = 铝水重量×铝价 |
|
||||
| `cost_breakdown.aluminum_weight_kg` | float | 铝水重量(含 1.05 损耗) |
|
||||
| `cost_breakdown.aluminum_price_per_kg` | float | 采用铝价 ¥/kg |
|
||||
| `cost_breakdown.price_basis_month` | string | 铝价月份 `YYYY-MM`(留痕;无月度价时为 null) |
|
||||
| `cost_breakdown.internal_code` | string | 内部牌号 |
|
||||
| `cost_breakdown.grade_group` | string | 材质族(决定机加工费率) |
|
||||
| `cost_breakdown.casting_cost` | float | 单件压铸成本 |
|
||||
| `cost_breakdown.die_casting_ton` | int | 压铸机吨位 |
|
||||
| `cost_breakdown.machining_cost` | float | 机加工成本 = 工时×费率 |
|
||||
| `cost_breakdown.machining_hours` | float | 机加工总工时(小时) |
|
||||
| `cost_breakdown.machining_rate_per_hour` | float | 机加工费率 ¥/h |
|
||||
| `cost_breakdown.post_process_cost` | float | 后处理成本 = 净重×工艺费率 |
|
||||
| `cost_breakdown.surface_treatment` | string | 表面处理工艺 |
|
||||
| `total_cost` | float | 四项成本小计 |
|
||||
| `overhead_rate` / `overhead` | float | 管理费率 / 管理费额 |
|
||||
| `profit_rate` | float | 利润率 |
|
||||
| `unit_price` | float | **最终单价** = (total_cost+overhead)×(1+profit_rate) |
|
||||
| `currency` | string | 固定 `CNY` |
|
||||
|
||||
**返回示例**(VHB-AT-001,30 万件/年,钻 Φ6×2/攻 M6×1.0×2/铰 Φ8H7×1/铣面×1):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"material_code": "VHB-AT-001",
|
||||
"part_name": "变速箱阀体外壳",
|
||||
"cost_breakdown": {
|
||||
"material_cost": 23.31,
|
||||
"aluminum_weight_kg": 1.26,
|
||||
"aluminum_price_per_kg": 18.5,
|
||||
"price_basis_month": "2026-08",
|
||||
"internal_code": "AL-ADC12",
|
||||
"grade_group": "铝合金",
|
||||
"casting_cost": 2.17,
|
||||
"die_casting_ton": 400,
|
||||
"machining_cost": 0.53,
|
||||
"machining_hours": 0.0089,
|
||||
"machining_rate_per_hour": 60.0,
|
||||
"post_process_cost": 6.0,
|
||||
"surface_treatment": "阳极氧化"
|
||||
},
|
||||
"total_cost": 32.01,
|
||||
"overhead_rate": 0.12,
|
||||
"overhead": 3.84,
|
||||
"profit_rate": 0.15,
|
||||
"unit_price": 41.23,
|
||||
"currency": "CNY"
|
||||
}
|
||||
```
|
||||
|
||||
**失败返回**:`{"success": false, "message": "未找到物料 XXX"}`
|
||||
|
||||
---
|
||||
|
||||
## 4 附录
|
||||
|
||||
### 4.1 核心公式
|
||||
|
||||
```
|
||||
铝水重量(kg) = 体积(cm³) × 密度(g/cm³) / 1000 × 1.05(损耗系数)
|
||||
材料成本 = 铝水重量 × 铝锭单价(当月月度价,缺省 18.5)
|
||||
压铸成本 = (节拍秒 / 3600) × 机台费率 / 模穴数 ← 吨位固化节拍
|
||||
机加工成本 = Σ(工序固化节拍×数量) / 3600 × 材质族费率
|
||||
后处理成本 = 净重(kg) × 表面处理费率
|
||||
单价 = (四项成本小计 × (1+管理费率)) × (1+利润率)
|
||||
```
|
||||
|
||||
### 4.2 依赖数据表
|
||||
|
||||
| 表 | 用途 |
|
||||
|----|------|
|
||||
| `material_master` | 物料主数据(体积/密度/吨位/后处理) |
|
||||
| `material_grade_mapping` | 五标准体系牌号对照 + 材质族 + 参考铝价 |
|
||||
| `material_price_history` | 月度铝价留痕(核价取最新月) |
|
||||
| `die_casting_params` | 吨位 → 节拍/费率/模穴数 |
|
||||
| `machining_cycle` | 工序规格固化节拍与刀具费率 |
|
||||
| `machining_rate` | 材质族机加工费率(60/75/80 ¥/h) |
|
||||
| `post_process_rate` | 表面处理费率(¥/kg) |
|
||||
|
||||
### 4.3 与 CRM 服务的协作
|
||||
|
||||
ERP 服务只读不写。报价存档链路(create_inquiry → save_quotation → update_opportunity)由 `mcp-for-crm` 承担,见《MCP for CRM — 工具说明文档》。
|
||||
@@ -1,60 +0,0 @@
|
||||
# MCP for ERP — 汽车零部件智能报价 ERP 数据服务
|
||||
|
||||
## 概述
|
||||
|
||||
为汽车零部件(小型阀体外壳)智能报价智能体提供 ERP 数据访问能力,通过 MCP 协议暴露 5 个工具。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: MCPServer(mcp 2.0.0 内置高级框架)
|
||||
- **语言**: Python 3.11+
|
||||
- **数据库**: PostgreSQL 15+
|
||||
- **传输协议**: Streamable HTTP(远程调用)
|
||||
|
||||
## 工具清单
|
||||
|
||||
| 工具名 | 功能 | 核价环节 |
|
||||
|---|---|---|
|
||||
| `query_material_master` | 按编码/参数查询物料主数据(含体积、重量、吨位) | 基础数据 |
|
||||
| `match_material_grade` | 材质牌号对照(GB/ASTM/JIS/EN),返回等效牌号+密度 | 材质确认 |
|
||||
| `get_die_casting_params` | 根据零件体积+吨位返回压铸参数(节拍、铝水重量) | **压铸成本** |
|
||||
| `get_machining_estimate` | 根据孔数/孔深/螺纹规格返回机加工工时估算 | **机加工成本** |
|
||||
| `calculate_part_cost` | 汇总核价:材料+压铸+机加工+后处理+管理费+利润 | **最终报价** |
|
||||
|
||||
## 核价逻辑
|
||||
|
||||
```
|
||||
1. 铝水重量 = 体积(cm³) × 密度(g/cm³) / 1000 × 1.05(损耗)
|
||||
2. 材料成本 = 铝水重量 × 铝锭单价
|
||||
3. 压铸成本 = (节拍秒/3600) × 机台费率 / 模穴数
|
||||
4. 机加工成本 = 总工时(小时) × 加工中心费率(60元/时)
|
||||
5. 后处理成本 = 零件重量(kg) × 后处理单价
|
||||
6. 总成本 = 材料 + 压铸 + 机加工 + 后处理
|
||||
7. 报价 = 总成本 × (1+管理费率12%) × (1+利润率15%)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
cd src
|
||||
python seed.py # 初始化数据库(可选,数据已在共享数据库中)
|
||||
python server.py # 启动 MCP Server(端口 8001)
|
||||
```
|
||||
|
||||
## 服务地址
|
||||
|
||||
- **HTTP 端点**: `http://0.0.0.0:8001/mcp`
|
||||
- **传输协议**: Streamable HTTP
|
||||
|
||||
## 数据库配置
|
||||
|
||||
通过环境变量配置:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `ERP_DB_HOST` | 10.100.154.100 | PostgreSQL 主机 |
|
||||
| `ERP_DB_PORT` | 5432 | 端口 |
|
||||
| `ERP_DB_USER` | postgres | 用户名 |
|
||||
| `ERP_DB_PASSWORD` | digiwin | 密码 |
|
||||
| `ERP_DB_NAME` | smart_quotation_auto | 数据库名 |
|
||||
@@ -1,4 +0,0 @@
|
||||
mcp>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
httpx>=0.27.0
|
||||
@@ -1,234 +0,0 @@
|
||||
-- ============================================================
|
||||
-- MCP for ERP — 汽车零部件智能报价 ERP 数据服务
|
||||
-- 数据库:smart_quotation_auto
|
||||
-- v3: 幂等可重跑(唯一约束+去重);扩充至 16 物料/13 牌号/9 吨位/26 节拍/10 后处理
|
||||
-- 新增 O1 material_price_history、O2 machining_rate
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 物料主数据(含体积,用于算铝水重量)
|
||||
CREATE TABLE IF NOT EXISTS material_master (
|
||||
material_code VARCHAR(50) PRIMARY KEY,
|
||||
part_name VARCHAR(200) NOT NULL,
|
||||
part_category VARCHAR(50) NOT NULL,
|
||||
material_grade VARCHAR(50) NOT NULL,
|
||||
density DECIMAL(6,3) NOT NULL DEFAULT 2.700,
|
||||
volume_cc DECIMAL(10,2) NOT NULL,
|
||||
net_weight_kg DECIMAL(8,3) NOT NULL,
|
||||
die_casting_ton INTEGER NOT NULL,
|
||||
surface_treatment VARCHAR(50),
|
||||
unit VARCHAR(10) DEFAULT '件',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. 材质牌号对照表
|
||||
CREATE TABLE IF NOT EXISTS material_grade_mapping (
|
||||
id SERIAL PRIMARY KEY,
|
||||
grade_group VARCHAR(50) NOT NULL,
|
||||
gb_code VARCHAR(80),
|
||||
astm_code VARCHAR(80),
|
||||
jis_code VARCHAR(80),
|
||||
en_code VARCHAR(80),
|
||||
iso_code VARCHAR(80),
|
||||
internal_code VARCHAR(50) NOT NULL,
|
||||
density DECIMAL(6,3) NOT NULL,
|
||||
price_per_kg DECIMAL(10,2) NOT NULL
|
||||
);
|
||||
|
||||
-- 3. 压铸参数表(吨位 → 固定节拍)
|
||||
CREATE TABLE IF NOT EXISTS die_casting_params (
|
||||
tonnage INTEGER PRIMARY KEY,
|
||||
shot_weight_kg DECIMAL(8,3) NOT NULL,
|
||||
cycle_time_sec DECIMAL(8,2) NOT NULL,
|
||||
machine_rate DECIMAL(10,2) NOT NULL,
|
||||
mold_cavities INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
-- 4. 机加工节拍表(固化工装节拍)
|
||||
CREATE TABLE IF NOT EXISTS machining_cycle (
|
||||
id SERIAL PRIMARY KEY,
|
||||
operation_type VARCHAR(30) NOT NULL,
|
||||
spec VARCHAR(50) NOT NULL,
|
||||
cycle_time_sec DECIMAL(8,2) NOT NULL,
|
||||
tool_rate DECIMAL(8,4) DEFAULT 0,
|
||||
notes VARCHAR(200)
|
||||
);
|
||||
|
||||
-- 5. 后处理费率表(按重量计价)
|
||||
CREATE TABLE IF NOT EXISTS post_process_rate (
|
||||
id SERIAL PRIMARY KEY,
|
||||
process_type VARCHAR(50) NOT NULL,
|
||||
unit VARCHAR(10) DEFAULT 'kg',
|
||||
rate DECIMAL(10,2) NOT NULL,
|
||||
currency VARCHAR(10) DEFAULT 'CNY'
|
||||
);
|
||||
|
||||
-- 6. O1 材料价格历史表(月度参考价,核价取最新月份)
|
||||
CREATE TABLE IF NOT EXISTS material_price_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
internal_code VARCHAR(50) NOT NULL,
|
||||
price_date DATE NOT NULL,
|
||||
price_per_kg DECIMAL(10,2) NOT NULL,
|
||||
source VARCHAR(100) DEFAULT '市场参考价',
|
||||
UNIQUE (internal_code, price_date)
|
||||
);
|
||||
|
||||
-- 7. O2 机加工费率表(按材质族,替换硬编码 60 元/h)
|
||||
CREATE TABLE IF NOT EXISTS machining_rate (
|
||||
grade_group VARCHAR(50) PRIMARY KEY,
|
||||
rate_per_hour DECIMAL(10,2) NOT NULL,
|
||||
notes VARCHAR(200)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 幂等性:去重 + 唯一约束(可重复执行)
|
||||
-- ============================================================
|
||||
|
||||
DELETE FROM machining_cycle a USING machining_cycle b
|
||||
WHERE a.id > b.id AND a.operation_type = b.operation_type AND a.spec = b.spec;
|
||||
|
||||
DELETE FROM material_grade_mapping a USING material_grade_mapping b
|
||||
WHERE a.id > b.id AND a.internal_code = b.internal_code;
|
||||
|
||||
DELETE FROM post_process_rate a USING post_process_rate b
|
||||
WHERE a.id > b.id AND a.process_type = b.process_type;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE machining_cycle ADD CONSTRAINT uq_machining_cycle UNIQUE (operation_type, spec);
|
||||
EXCEPTION WHEN duplicate_table THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE material_grade_mapping ADD CONSTRAINT uq_grade_mapping UNIQUE (internal_code);
|
||||
EXCEPTION WHEN duplicate_table THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE post_process_rate ADD CONSTRAINT uq_post_process UNIQUE (process_type);
|
||||
EXCEPTION WHEN duplicate_table THEN NULL; END $$;
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据
|
||||
-- ============================================================
|
||||
|
||||
-- 物料主数据(16 种阀体外壳:铝12/灰铁3/球铁1,吨位 160~1000T 全覆盖)
|
||||
INSERT INTO material_master (material_code, part_name, part_category, material_grade, density, volume_cc, net_weight_kg, die_casting_ton, surface_treatment) VALUES
|
||||
('VHB-AT-001', '变速箱阀体外壳', '阀体外壳', 'ADC12', 2.700, 444.44, 1.200, 400, '阳极氧化'),
|
||||
('VHB-BK-002', '制动阀体壳', '阀体外壳', 'HT250', 7.200, 388.89, 2.800, 630, '电泳涂装'),
|
||||
('VHB-SP-003', '转向器阀体壳', '阀体外壳', 'A356-T6', 2.650, 296.30, 0.785, 280, '钝化'),
|
||||
('VHB-EG-004', '发动机油路阀体壳', '阀体外壳', 'EN AC-4600', 2.700, 555.56, 1.500, 400, '阳极氧化'),
|
||||
('VHB-CL-005', '离合器控制阀体壳', '阀体外壳', 'ADC10', 2.700, 480.00, 1.300, 400, '阳极氧化'),
|
||||
('VHB-TR-006', '变速箱液压控制阀体', '阀体外壳', 'AlSi9Cu3', 2.700, 620.00, 1.670, 630, '电泳涂装'),
|
||||
('VHB-EB-007', '电子制动阀体壳', '阀体外壳', 'AlSi10MnMg', 2.650, 350.00, 0.930, 400, '钝化'),
|
||||
('VHB-QT-008', '悬架阻尼阀体壳', '阀体外壳', 'QT500-7', 7.100, 520.00, 3.690, 630, '喷涂'),
|
||||
('VHB-MN-009', '微型泵阀体壳', '阀体外壳', 'ADC12', 2.700, 96.30, 0.260, 160, '钝化'),
|
||||
('VHB-LG-010', '大型主阀体壳', '阀体外壳', 'ADC12', 2.700, 1037.00, 2.800, 800, '电泳涂装'),
|
||||
('VHB-TC-011', '涡轮增压控制阀体壳', '阀体外壳', 'ADC12', 2.700, 320.00, 0.860, 350, '钝化'),
|
||||
('VHB-EG-012', 'EGR阀体壳', '阀体外壳', 'ADC10', 2.700, 265.00, 0.720, 280, '钝化'),
|
||||
('VHB-WP-013', '水泵阀体壳', '阀体外壳', 'AlSi12', 2.650, 380.00, 1.010, 400, '阳极氧化'),
|
||||
('VHB-TM-014', '热管理阀体壳(新能源)', '阀体外壳', 'AlSi10MnMg', 2.650, 505.00, 1.340, 500, '钝化'),
|
||||
('VHB-OP-015', '机油泵阀体壳', '阀体外壳', 'ZL101', 2.650, 210.00, 0.560, 200, '钝化'),
|
||||
('VHB-HD-016', '重卡制动阀体壳', '阀体外壳', 'HT300', 7.250, 850.00, 6.160, 1000, '达克罗')
|
||||
ON CONFLICT (material_code) DO NOTHING;
|
||||
|
||||
-- 材质牌号对照表(13 条:铝合金/铸铝/灰铸铁/球墨铸铁)
|
||||
INSERT INTO material_grade_mapping (grade_group, gb_code, astm_code, jis_code, en_code, iso_code, internal_code, density, price_per_kg) VALUES
|
||||
('铝合金', 'ZAlSi10Cu(ADC12)', 'A380', 'ADC12', 'EN AC-4600', 'AlSi9Cu3', 'AL-ADC12', 2.700, 18.50),
|
||||
('铝合金', 'ZAlSi7Mg(A356)', 'A356', 'AC4C-T6', 'EN AC-4210', 'AlSi7Mg', 'AL-A356', 2.650, 20.00),
|
||||
('铝合金', 'ZAlSi9Cu(AlSi9Cu)', 'B443', 'AC4B', 'EN AC-4600', 'AlSi9Cu', 'AL-4600', 2.700, 18.00),
|
||||
('铸铝', 'ZL101', '356.0', 'AC4C', 'EN AC-4200', 'AlSi7', 'AL-ZL101', 2.650, 19.00),
|
||||
('铸铁', 'HT250', 'G2500', 'FC250', 'EN-GJL-250', 'GJL-250', 'IR-HT250', 7.200, 6.50),
|
||||
('球墨铸铁', 'QT500-7', 'D5007', 'FCD500', 'EN-GJS-500', 'GJS-500', 'IR-QT500', 7.100, 7.20),
|
||||
('铝合金', 'ZAlSi8Cu(ADC10)', 'A384', 'ADC10', 'EN AC-4650', 'AlSi8Cu', 'AL-ADC10', 2.700, 18.20),
|
||||
('铝合金', 'ZAlSi10MnMg', '—', '—', 'AlSi10MnMg', 'AlSi10MnMg', 'AL-S10MM', 2.650, 22.00),
|
||||
('铝合金', 'ZAlSi12', '413.0', 'AC3A', 'EN AC-4420', 'AlSi12', 'AL-S12', 2.650, 17.80),
|
||||
('铸铁', 'HT200', 'G3000', 'FC200', 'EN-GJL-200', 'GJL-200', 'IR-HT200', 7.200, 6.00),
|
||||
('铸铁', 'HT300', 'G3500', 'FC300', 'EN-GJL-300', 'GJL-300', 'IR-HT300', 7.250, 6.80),
|
||||
('球墨铸铁', 'QT450-10', 'D4510', 'FCD450', 'EN-GJS-450', 'GJS-450', 'IR-QT450', 7.100, 7.00),
|
||||
('球墨铸铁', 'QT600-3', 'D6003', 'FCD600', 'EN-GJS-600', 'GJS-600', 'IR-QT600', 7.100, 7.50)
|
||||
ON CONFLICT (internal_code) DO NOTHING;
|
||||
|
||||
-- 球铁牌号组别归位(历史数据曾归入"铸铁"组)
|
||||
UPDATE material_grade_mapping SET grade_group = '球墨铸铁' WHERE internal_code LIKE 'IR-QT%';
|
||||
|
||||
-- 压铸参数表(9 档吨位:160~1000T;故意不含 1250T 用于回退测试)
|
||||
INSERT INTO die_casting_params (tonnage, shot_weight_kg, cycle_time_sec, machine_rate, mold_cavities) VALUES
|
||||
( 160, 0.50, 15.00, 120.00, 2),
|
||||
( 200, 0.80, 18.00, 140.00, 2),
|
||||
( 280, 1.20, 22.00, 180.00, 1),
|
||||
( 350, 1.80, 26.00, 220.00, 1),
|
||||
( 400, 2.50, 30.00, 260.00, 1),
|
||||
( 500, 3.20, 36.00, 320.00, 1),
|
||||
( 630, 4.00, 42.00, 380.00, 1),
|
||||
( 800, 6.50, 55.00, 520.00, 1),
|
||||
(1000, 8.50, 65.00, 650.00, 1)
|
||||
ON CONFLICT (tonnage) DO NOTHING;
|
||||
|
||||
-- 机加工节拍表(26 条全规格谱系;故意不含 Φ25钻/M18攻 用于回退测试)
|
||||
INSERT INTO machining_cycle (operation_type, spec, cycle_time_sec, tool_rate, notes) VALUES
|
||||
('drill', 'Φ3', 2.00, 0.10, '通孔'),
|
||||
('drill', 'Φ4', 2.20, 0.12, '通孔'),
|
||||
('drill', 'Φ5', 2.50, 0.13, '通孔'),
|
||||
('drill', 'Φ6', 3.00, 0.15, '通孔'),
|
||||
('drill', 'Φ8', 4.00, 0.20, '通孔'),
|
||||
('drill', 'Φ10', 5.00, 0.25, '通孔'),
|
||||
('drill', 'Φ12', 6.00, 0.30, '通孔'),
|
||||
('drill', 'Φ14', 7.00, 0.35, '通孔'),
|
||||
('drill', 'Φ16', 8.00, 0.40, '通孔'),
|
||||
('drill', 'Φ20', 10.00, 0.50, '通孔'),
|
||||
('drill', 'Φ24', 12.00, 0.60, '通孔'),
|
||||
('tap', 'M4×0.7', 3.00, 0.15, '盲孔'),
|
||||
('tap', 'M5×0.8', 3.50, 0.18, '盲孔'),
|
||||
('tap', 'M6×1.0', 4.00, 0.20, '盲孔'),
|
||||
('tap', 'M8×1.25', 5.00, 0.25, '盲孔'),
|
||||
('tap', 'M10×1.5', 6.00, 0.30, '盲孔'),
|
||||
('tap', 'M12×1.75', 7.00, 0.35, '盲孔'),
|
||||
('tap', 'M14×2.0', 8.00, 0.40, '盲孔'),
|
||||
('tap', 'M16×2.0', 9.00, 0.45, '盲孔'),
|
||||
('ream', 'Φ6H7', 5.00, 0.45, '铰孔'),
|
||||
('ream', 'Φ8H7', 6.00, 0.50, '铰孔'),
|
||||
('ream', 'Φ10H7', 7.00, 0.60, '铰孔'),
|
||||
('ream', 'Φ12H7', 8.00, 0.70, '铰孔'),
|
||||
('ream', 'Φ16H7', 10.00, 0.90, '铰孔'),
|
||||
('ream', 'Φ20H7', 12.00, 1.10, '铰孔'),
|
||||
('mill', 'face', 12.00, 0.80, '铣面(单面)')
|
||||
ON CONFLICT (operation_type, spec) DO NOTHING;
|
||||
|
||||
-- 后处理费率表(10 种;故意不含"镀铬"用于回退测试)
|
||||
INSERT INTO post_process_rate (process_type, unit, rate) VALUES
|
||||
('喷砂', 'kg', 1.50),
|
||||
('钝化', 'kg', 2.00),
|
||||
('阳极氧化', 'kg', 5.00),
|
||||
('电泳涂装', 'kg', 3.50),
|
||||
('喷涂', 'kg', 2.50),
|
||||
('粉末喷涂', 'kg', 3.00),
|
||||
('磷化', 'kg', 1.80),
|
||||
('达克罗', 'kg', 4.00),
|
||||
('硬质阳极氧化', 'kg', 8.00),
|
||||
('化学镀镍', 'kg', 9.00)
|
||||
ON CONFLICT (process_type) DO NOTHING;
|
||||
|
||||
-- O2 机加工费率表(按材质族)
|
||||
INSERT INTO machining_rate (grade_group, rate_per_hour, notes) VALUES
|
||||
('铝合金', 60.00, '铝合金常规机加工费率'),
|
||||
('铸铝', 60.00, '铸铝同铝合金费率'),
|
||||
('铸铁', 75.00, '灰铸铁刀具损耗高,费率上浮'),
|
||||
('球墨铸铁', 80.00, '球铁强度高,机加工费率最高')
|
||||
ON CONFLICT (grade_group) DO NOTHING;
|
||||
|
||||
-- O1 材料价格历史表(2025-01 ~ 2026-08 共 20 个月 × 13 牌号,最新月系数=1.00 与对照表价格一致)
|
||||
WITH factors(price_date, al_factor, ir_factor) AS (VALUES
|
||||
('2025-01-01'::date, 0.92, 0.95), ('2025-02-01'::date, 0.93, 0.96),
|
||||
('2025-03-01'::date, 0.95, 0.96), ('2025-04-01'::date, 0.94, 0.97),
|
||||
('2025-05-01'::date, 0.96, 0.98), ('2025-06-01'::date, 0.98, 0.99),
|
||||
('2025-07-01'::date, 0.97, 0.98), ('2025-08-01'::date, 0.99, 1.00),
|
||||
('2025-09-01'::date, 1.01, 1.01), ('2025-10-01'::date, 1.00, 1.00),
|
||||
('2025-11-01'::date, 0.98, 0.99), ('2025-12-01'::date, 0.97, 0.98),
|
||||
('2026-01-01'::date, 0.99, 0.99), ('2026-02-01'::date, 1.02, 1.00),
|
||||
('2026-03-01'::date, 1.01, 1.01), ('2026-04-01'::date, 1.00, 1.00),
|
||||
('2026-05-01'::date, 0.99, 0.99), ('2026-06-01'::date, 1.01, 1.00),
|
||||
('2026-07-01'::date, 1.00, 1.00), ('2026-08-01'::date, 1.00, 1.00)
|
||||
)
|
||||
INSERT INTO material_price_history (internal_code, price_date, price_per_kg, source)
|
||||
SELECT m.internal_code, f.price_date,
|
||||
ROUND(m.price_per_kg * CASE WHEN m.grade_group IN ('铸铁', '球墨铸铁') THEN f.ir_factor ELSE f.al_factor END, 2),
|
||||
'市场参考价'
|
||||
FROM material_grade_mapping m CROSS JOIN factors f
|
||||
ON CONFLICT (internal_code, price_date) DO NOTHING;
|
||||
@@ -1,119 +0,0 @@
|
||||
"""动态 Bearer Token 鉴权(通过 mcp-auth 后端 API 校验,不直连鉴权库)。
|
||||
|
||||
token 校验由 MCP 服务调用 mcp-auth 后端 `POST /api/auth/verify-token` 完成,
|
||||
后端统一查 mcp_auth.mcp_token 表。后端通过 per-service API Key 识别调用方服务,
|
||||
MCP 服务无需自报 service。进程内 LRU 缓存 30s,减少 API 调用。
|
||||
|
||||
环境变量:
|
||||
MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port}
|
||||
MCP_AUTH_API_URL mcp-auth 后端地址,如 http://localhost:8000
|
||||
MCP_AUTH_API_KEY 本服务的 API Key(由 mcp-auth 后端签发,per-service 唯一)
|
||||
MCP_AUTH_CACHE_TTL 缓存秒数,默认 30
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
|
||||
_CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30"))
|
||||
|
||||
# httpx 异步客户端(进程级单例,复用连接池)
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None:
|
||||
_http_client = httpx.AsyncClient(timeout=5.0)
|
||||
return _http_client
|
||||
|
||||
|
||||
async def close_auth_client() -> None:
|
||||
"""关闭 httpx 客户端(进程退出时调用)。"""
|
||||
global _http_client
|
||||
if _http_client is not None:
|
||||
await _http_client.aclose()
|
||||
_http_client = None
|
||||
|
||||
|
||||
class ApiTokenVerifier:
|
||||
"""通过 mcp-auth 后端 API 校验 Bearer Token。
|
||||
|
||||
进程内 LRU 缓存(token_hash -> (valid, client_id, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
|
||||
"""
|
||||
|
||||
def __init__(self, api_url: str, api_key: str):
|
||||
self._api_url = api_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._cache: dict[str, tuple[bool, str | None, float]] = {}
|
||||
|
||||
def invalidate(self, token: str | None = None) -> None:
|
||||
"""清缓存:token=None 清全部,否则清单个。"""
|
||||
if token is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(hashlib.sha256(token.encode()).hexdigest(), None)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
# 1. 查缓存
|
||||
now = time.time()
|
||||
cached = self._cache.get(token_hash)
|
||||
if cached is not None and (now - cached[2]) < _CACHE_TTL:
|
||||
valid, client_id, _ = cached
|
||||
if not valid:
|
||||
return None
|
||||
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
|
||||
|
||||
# 2. 调用后端校验 API(后端从 X-API-Key 识别 service,无需传 service)
|
||||
try:
|
||||
client = _get_http_client()
|
||||
resp = await client.post(
|
||||
f"{self._api_url}/api/auth/verify-token",
|
||||
headers={"X-API-Key": self._api_key},
|
||||
json={"token": token},
|
||||
)
|
||||
except Exception:
|
||||
# 后端不可达,缓存短时间避免雪崩
|
||||
self._cache[token_hash] = (False, None, now)
|
||||
return None
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
valid = data.get("valid", False)
|
||||
client_id = data.get("client_id")
|
||||
self._cache[token_hash] = (valid, client_id, now)
|
||||
if valid:
|
||||
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
|
||||
return None
|
||||
else:
|
||||
# 非 200(含 401 API Key 错误),缓存避免雪崩
|
||||
self._cache[token_hash] = (False, None, now)
|
||||
return None
|
||||
|
||||
|
||||
def get_auth(port: int) -> tuple[AuthSettings, ApiTokenVerifier]:
|
||||
"""构建 MCPServer 的 (auth, token_verifier) 参数。
|
||||
|
||||
service 由后端从 API Key 自动识别,MCP 服务无需配置 MCP_AUTH_SERVICE。
|
||||
"""
|
||||
url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}"
|
||||
api_url = os.getenv("MCP_AUTH_API_URL", "http://localhost:8000")
|
||||
api_key = os.getenv("MCP_AUTH_API_KEY", "")
|
||||
return (
|
||||
AuthSettings(issuer_url=url, resource_server_url=url),
|
||||
ApiTokenVerifier(api_url=api_url, api_key=api_key),
|
||||
)
|
||||
|
||||
|
||||
def get_caller() -> str:
|
||||
"""工具内获取当前调用方标识(未认证时返回 anonymous)。"""
|
||||
access_token = get_access_token()
|
||||
return access_token.client_id if access_token else "anonymous"
|
||||
@@ -1,28 +0,0 @@
|
||||
"""PostgreSQL 连接池管理"""
|
||||
|
||||
import asyncpg
|
||||
import os
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("ERP_DB_HOST", "10.100.154.100"),
|
||||
"port": int(os.getenv("ERP_DB_PORT", "5432")),
|
||||
"user": os.getenv("ERP_DB_USER", "postgres"),
|
||||
"password": os.getenv("ERP_DB_PASSWORD", "digiwin"),
|
||||
"database": os.getenv("ERP_DB_NAME", "smart_quotation_auto"),
|
||||
}
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""初始化 ERP 数据库表和种子数据"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from db import get_pool, close_pool
|
||||
|
||||
async def main():
|
||||
pool = await get_pool()
|
||||
sql_path = Path(__file__).parent.parent / "sql" / "init.sql"
|
||||
sql = sql_path.read_text(encoding="utf-8")
|
||||
await pool.execute(sql)
|
||||
print("ERP 数据库初始化完成")
|
||||
await close_pool()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,353 +0,0 @@
|
||||
"""
|
||||
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"))
|
||||
@@ -1,239 +0,0 @@
|
||||
# MCP for ERP — 数据表结构与种子数据说明文档
|
||||
|
||||
> 数据库:PostgreSQL 15+,库 `smart_quotation_auto`(与 CRM 服务共库不同表)
|
||||
> 建表/种子脚本:`sql/init.sql`(v3,幂等可重跑);执行入口:`cd src && python3 seed.py`
|
||||
> 版本:v1.0(2026-08-09)
|
||||
|
||||
---
|
||||
|
||||
## 1 概览
|
||||
|
||||
| # | 表名 | 用途 | 主键 | 种子行数 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | `material_master` | 物料主数据(体积/密度/吨位/后处理) | material_code | 16 |
|
||||
| 2 | `material_grade_mapping` | 五标准体系牌号对照 | id(SERIAL) | 13 |
|
||||
| 3 | `die_casting_params` | 压铸参数(吨位→固化节拍/费率/模穴) | tonnage | 9 |
|
||||
| 4 | `machining_cycle` | 机加工固化工装节拍 | id(SERIAL) | 26 |
|
||||
| 5 | `post_process_rate` | 后处理费率(¥/kg) | id(SERIAL) | 10 |
|
||||
| 6 | `material_price_history` | 月度材料价格留痕 | id(SERIAL) | 260(13 牌号×20 月) |
|
||||
| 7 | `machining_rate` | 材质族机加工费率 | grade_group | 4 |
|
||||
|
||||
**表关系**:`material_master.material_grade` →(经 match_material_grade 模糊对照)→ `material_grade_mapping`(internal_code/grade_group)→ `material_price_history`(按 internal_code 取最新月价)、`machining_rate`(按 grade_group 取费率);`material_master.die_casting_ton` → `die_casting_params.tonnage`;机加工工时查 `machining_cycle`(operation_type+spec);后处理查 `post_process_rate`(process_type = material_master.surface_treatment)。
|
||||
|
||||
---
|
||||
|
||||
## 2 表结构详解
|
||||
|
||||
### 2.1 material_master — 物料主数据
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `material_code` | VARCHAR(50) | PRIMARY KEY | 物料编码,如 `VHB-AT-001` |
|
||||
| `part_name` | VARCHAR(200) | NOT NULL | 零件名称 |
|
||||
| `part_category` | VARCHAR(50) | NOT NULL | 零件类别(种子全为「阀体外壳」) |
|
||||
| `material_grade` | VARCHAR(50) | NOT NULL | 客户材质牌号(对照表输入) |
|
||||
| `density` | DECIMAL(6,3) | NOT NULL DEFAULT 2.700 | 密度 g/cm³ |
|
||||
| `volume_cc` | DECIMAL(10,2) | NOT NULL | 零件体积 cm³(**算铝水重量的依据**) |
|
||||
| `net_weight_kg` | DECIMAL(8,3) | NOT NULL | 净重 kg(**算后处理成本的依据**) |
|
||||
| `die_casting_ton` | INTEGER | NOT NULL | 压铸机吨位(关联 die_casting_params) |
|
||||
| `surface_treatment` | VARCHAR(50) | — | 表面处理(关联 post_process_rate) |
|
||||
| `unit` | VARCHAR(10) | DEFAULT '件' | 计量单位 |
|
||||
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 建档时间 |
|
||||
|
||||
### 2.2 material_grade_mapping — 材质牌号对照表
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `id` | SERIAL | PRIMARY KEY | 自增主键 |
|
||||
| `grade_group` | VARCHAR(50) | NOT NULL | 材质族:铝合金/铸铝/铸铁/球墨铸铁(决定机加工费率) |
|
||||
| `gb_code` | VARCHAR(80) | — | GB 牌号 |
|
||||
| `astm_code` | VARCHAR(80) | — | ASTM 牌号 |
|
||||
| `jis_code` | VARCHAR(80) | — | JIS 牌号 |
|
||||
| `en_code` | VARCHAR(80) | — | EN 牌号 |
|
||||
| `iso_code` | VARCHAR(80) | — | ISO 牌号 |
|
||||
| `internal_code` | VARCHAR(50) | NOT NULL,UNIQUE(uq_grade_mapping) | 内部等效牌号 |
|
||||
| `density` | DECIMAL(6,3) | NOT NULL | 密度 g/cm³ |
|
||||
| `price_per_kg` | DECIMAL(10,2) | NOT NULL | 铝锭/材料参考价 ¥/kg(兜底价) |
|
||||
|
||||
> 对照匹配对六列码值做大小写不敏感 LIKE 模糊匹配;带热处理后缀(如 A356-T6)自动剥后缀重试。
|
||||
|
||||
### 2.3 die_casting_params — 压铸参数表
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `tonnage` | INTEGER | PRIMARY KEY | 压铸机吨位 |
|
||||
| `shot_weight_kg` | DECIMAL(8,3) | NOT NULL | 射出重量 kg(参考值) |
|
||||
| `cycle_time_sec` | DECIMAL(8,2) | NOT NULL | 模次节拍 秒/模(**吨位固化**) |
|
||||
| `machine_rate` | DECIMAL(10,2) | NOT NULL | 机台费率 ¥/小时 |
|
||||
| `mold_cavities` | INTEGER | DEFAULT 1 | 模穴数(压铸成本按穴分摊) |
|
||||
|
||||
### 2.4 machining_cycle — 机加工节拍表(固化工装节拍)
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `id` | SERIAL | PRIMARY KEY | 自增主键 |
|
||||
| `operation_type` | VARCHAR(30) | NOT NULL | drill 钻孔 / tap 攻丝 / ream 铰孔 / mill 铣面 |
|
||||
| `spec` | VARCHAR(50) | NOT NULL | 规格:Φ6、M6×1.0、Φ8H7、face;UNIQUE(operation_type, spec) |
|
||||
| `cycle_time_sec` | DECIMAL(8,2) | NOT NULL | 单件固化节拍(秒) |
|
||||
| `tool_rate` | DECIMAL(8,4) | DEFAULT 0 | 单件刀具成本 ¥ |
|
||||
| `notes` | VARCHAR(200) | — | 备注(通孔/盲孔等) |
|
||||
|
||||
### 2.5 post_process_rate — 后处理费率表
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `id` | SERIAL | PRIMARY KEY | 自增主键 |
|
||||
| `process_type` | VARCHAR(50) | NOT NULL,UNIQUE(uq_post_process) | 工艺名(与物料 surface_treatment 对应) |
|
||||
| `unit` | VARCHAR(10) | DEFAULT 'kg' | 计价单位 |
|
||||
| `rate` | DECIMAL(10,2) | NOT NULL | 费率 ¥/kg |
|
||||
| `currency` | VARCHAR(10) | DEFAULT 'CNY' | 币种 |
|
||||
|
||||
### 2.6 material_price_history — 材料价格历史表
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `id` | SERIAL | PRIMARY KEY | 自增主键 |
|
||||
| `internal_code` | VARCHAR(50) | NOT NULL | 内部牌号;UNIQUE(internal_code, price_date) |
|
||||
| `price_date` | DATE | NOT NULL | 价格月份(每月 1 日) |
|
||||
| `price_per_kg` | DECIMAL(10,2) | NOT NULL | 当月参考价 ¥/kg |
|
||||
| `source` | VARCHAR(100) | DEFAULT '市场参考价' | 数据来源留痕 |
|
||||
|
||||
> 核价取法:`ORDER BY price_date DESC LIMIT 1`(最新月),并在返回中带 `price_basis_month` 留痕。
|
||||
|
||||
### 2.7 machining_rate — 机加工费率表
|
||||
|
||||
| 列 | 类型 | 约束/默认 | 说明 |
|
||||
|----|------|-----------|------|
|
||||
| `grade_group` | VARCHAR(50) | PRIMARY KEY | 材质族 |
|
||||
| `rate_per_hour` | DECIMAL(10,2) | NOT NULL | 费率 ¥/小时 |
|
||||
| `notes` | VARCHAR(200) | — | 备注 |
|
||||
|
||||
---
|
||||
|
||||
## 3 种子数据详解
|
||||
|
||||
### 3.1 material_master — 16 种阀体外壳
|
||||
|
||||
覆盖设计:铝合金 12 / 灰铸铁 2(HT250、HT300)/ 球墨铸铁 1(QT500-7)/ 铸铝 1(ZL101);吨位 160~1000T 全谱系;体积 96.3~1037 cm³。
|
||||
|
||||
| 物料编码 | 零件名称 | 牌号 | 密度 | 体积cm³ | 净重kg | 吨位 | 后处理 |
|
||||
|----------|----------|------|------|---------|--------|------|--------|
|
||||
| VHB-AT-001 | 变速箱阀体外壳 | ADC12 | 2.700 | 444.44 | 1.200 | 400 | 阳极氧化 |
|
||||
| VHB-BK-002 | 制动阀体壳 | HT250 | 7.200 | 388.89 | 2.800 | 630 | 电泳涂装 |
|
||||
| VHB-SP-003 | 转向器阀体壳 | A356-T6 | 2.650 | 296.30 | 0.785 | 280 | 钝化 |
|
||||
| VHB-EG-004 | 发动机油路阀体壳 | EN AC-4600 | 2.700 | 555.56 | 1.500 | 400 | 阳极氧化 |
|
||||
| VHB-CL-005 | 离合器控制阀体壳 | ADC10 | 2.700 | 480.00 | 1.300 | 400 | 阳极氧化 |
|
||||
| VHB-TR-006 | 变速箱液压控制阀体 | AlSi9Cu3 | 2.700 | 620.00 | 1.670 | 630 | 电泳涂装 |
|
||||
| VHB-EB-007 | 电子制动阀体壳 | AlSi10MnMg | 2.650 | 350.00 | 0.930 | 400 | 钝化 |
|
||||
| VHB-QT-008 | 悬架阻尼阀体壳 | QT500-7 | 7.100 | 520.00 | 3.690 | 630 | 喷涂 |
|
||||
| VHB-MN-009 | 微型泵阀体壳 | ADC12 | 2.700 | 96.30 | 0.260 | 160 | 钝化 |
|
||||
| VHB-LG-010 | 大型主阀体壳 | ADC12 | 2.700 | 1037.00 | 2.800 | 800 | 电泳涂装 |
|
||||
| VHB-TC-011 | 涡轮增压控制阀体壳 | ADC12 | 2.700 | 320.00 | 0.860 | 350 | 钝化 |
|
||||
| VHB-EG-012 | EGR阀体壳 | ADC10 | 2.700 | 265.00 | 0.720 | 280 | 钝化 |
|
||||
| VHB-WP-013 | 水泵阀体壳 | AlSi12 | 2.650 | 380.00 | 1.010 | 400 | 阳极氧化 |
|
||||
| VHB-TM-014 | 热管理阀体壳(新能源) | AlSi10MnMg | 2.650 | 505.00 | 1.340 | 500 | 钝化 |
|
||||
| VHB-OP-015 | 机油泵阀体壳 | ZL101 | 2.650 | 210.00 | 0.560 | 200 | 钝化 |
|
||||
| VHB-HD-016 | 重卡制动阀体壳 | HT300 | 7.250 | 850.00 | 6.160 | 1000 | 达克罗 |
|
||||
|
||||
### 3.2 material_grade_mapping — 13 条牌号对照
|
||||
|
||||
覆盖设计:铝合金 7 / 铸铝 1 / 铸铁 3 / 球墨铸铁 2;每条六码齐全(GB/ASTM/JIS/EN/ISO/内部码)。
|
||||
|
||||
| 材质族 | GB | ASTM | JIS | EN | ISO | 内部码 | 密度 | 参考价¥/kg |
|
||||
|--------|----|------|-----|----|----|--------|------|-----------|
|
||||
| 铝合金 | ZAlSi10Cu(ADC12) | A380 | ADC12 | EN AC-4600 | AlSi9Cu3 | AL-ADC12 | 2.700 | 18.50 |
|
||||
| 铝合金 | ZAlSi7Mg(A356) | A356 | AC4C-T6 | EN AC-4210 | AlSi7Mg | AL-A356 | 2.650 | 20.00 |
|
||||
| 铝合金 | ZAlSi9Cu(AlSi9Cu) | B443 | AC4B | EN AC-4600 | AlSi9Cu | AL-4600 | 2.700 | 18.00 |
|
||||
| 铸铝 | ZL101 | 356.0 | AC4C | EN AC-4200 | AlSi7 | AL-ZL101 | 2.650 | 19.00 |
|
||||
| 铸铁 | HT250 | G2500 | FC250 | EN-GJL-250 | GJL-250 | IR-HT250 | 7.200 | 6.50 |
|
||||
| 球墨铸铁 | QT500-7 | D5007 | FCD500 | EN-GJS-500 | GJS-500 | IR-QT500 | 7.100 | 7.20 |
|
||||
| 铝合金 | ZAlSi8Cu(ADC10) | A384 | ADC10 | EN AC-4650 | AlSi8Cu | AL-ADC10 | 2.700 | 18.20 |
|
||||
| 铝合金 | ZAlSi10MnMg | — | — | AlSi10MnMg | AlSi10MnMg | AL-S10MM | 2.650 | 22.00 |
|
||||
| 铝合金 | ZAlSi12 | 413.0 | AC3A | EN AC-4420 | AlSi12 | AL-S12 | 2.650 | 17.80 |
|
||||
| 铸铁 | HT200 | G3000 | FC200 | EN-GJL-200 | GJL-200 | IR-HT200 | 7.200 | 6.00 |
|
||||
| 铸铁 | HT300 | G3500 | FC300 | EN-GJL-300 | GJL-300 | IR-HT300 | 7.250 | 6.80 |
|
||||
| 球墨铸铁 | QT450-10 | D4510 | FCD450 | EN-GJS-450 | GJS-450 | IR-QT450 | 7.100 | 7.00 |
|
||||
| 球墨铸铁 | QT600-3 | D6003 | FCD600 | EN-GJS-600 | GJS-600 | IR-QT600 | 7.100 | 7.50 |
|
||||
|
||||
> 脚本含修正语句:`IR-QT%` 组别归位「球墨铸铁」(历史数据曾误归「铸铁」)。
|
||||
|
||||
### 3.3 die_casting_params — 9 档吨位
|
||||
|
||||
| 吨位 | 射出重量kg | 节拍秒/模 | 机台费率¥/h | 模穴数 |
|
||||
|------|-----------|-----------|-------------|--------|
|
||||
| 160 | 0.50 | 15.00 | 120.00 | **2**(一模两腔) |
|
||||
| 200 | 0.80 | 18.00 | 140.00 | **2** |
|
||||
| 280 | 1.20 | 22.00 | 180.00 | 1 |
|
||||
| 350 | 1.80 | 26.00 | 220.00 | 1 |
|
||||
| 400 | 2.50 | 30.00 | 260.00 | 1 |
|
||||
| 500 | 3.20 | 36.00 | 320.00 | 1 |
|
||||
| 630 | 4.00 | 42.00 | 380.00 | 1 |
|
||||
| 800 | 6.50 | 55.00 | 520.00 | 1 |
|
||||
| 1000 | 8.50 | 65.00 | 650.00 | 1 |
|
||||
|
||||
> **故意不含 1250T**:用于回退测试(未命中吨位场景)。
|
||||
|
||||
### 3.4 machining_cycle — 26 条固化节拍
|
||||
|
||||
| 工序 | 规格谱系 | 条数 | 节拍范围(秒) | 刀具费范围(¥) |
|
||||
|------|----------|------|--------------|----------------|
|
||||
| drill 钻孔 | Φ3 / Φ4 / Φ5 / Φ6 / Φ8 / Φ10 / Φ12 / Φ14 / Φ16 / Φ20 / Φ24 | 11 | 2.00~12.00 | 0.10~0.60 |
|
||||
| tap 攻丝 | M4×0.7 / M5×0.8 / M6×1.0 / M8×1.25 / M10×1.5 / M12×1.75 / M14×2.0 / M16×2.0 | 8 | 3.00~9.00 | 0.15~0.45 |
|
||||
| ream 铰孔 | Φ6H7 / Φ8H7 / Φ10H7 / Φ12H7 / Φ16H7 / Φ20H7 | 6 | 5.00~12.00 | 0.45~1.10 |
|
||||
| mill 铣面 | face(单面) | 1 | 12.00 | 0.80 |
|
||||
|
||||
> **故意不含 Φ25 钻孔 / M18 攻丝**:用于回退测试(未知规格静默跳过场景)。
|
||||
|
||||
### 3.5 post_process_rate — 10 种后处理
|
||||
|
||||
| 工艺 | 费率¥/kg | 工艺 | 费率¥/kg |
|
||||
|------|----------|------|----------|
|
||||
| 喷砂 | 1.50 | 粉末喷涂 | 3.00 |
|
||||
| 钝化 | 2.00 | 磷化 | 1.80 |
|
||||
| 阳极氧化 | 5.00 | 达克罗 | 4.00 |
|
||||
| 电泳涂装 | 3.50 | 硬质阳极氧化 | 8.00 |
|
||||
| 喷涂 | 2.50 | 化学镀镍 | 9.00 |
|
||||
|
||||
> **故意不含「镀铬」**:用于回退测试(未命中后处理场景,成本计 0)。
|
||||
|
||||
### 3.6 material_price_history — 260 行月度价格(2025-01 ~ 2026-08)
|
||||
|
||||
生成规则(脚本内 CTE 批量生成,非手工录入):
|
||||
|
||||
- 13 个内部牌号 × 20 个月 = 260 行,`source='市场参考价'`。
|
||||
- 每月价格 = 对照表参考价 × 月份系数;铝系用 `al_factor`(0.92~1.02 波动),铁系(铸铁/球墨铸铁)用 `ir_factor`(0.95~1.01 波动)。
|
||||
- **最新月(2026-08)系数 = 1.00**,即与对照表 `price_per_kg` 完全一致——保证核价结果与静态对照表口径可对账(如 AL-ADC12 当月价 18.50)。
|
||||
|
||||
### 3.7 machining_rate — 4 条材质族费率
|
||||
|
||||
| 材质族 | 费率¥/h | 备注 |
|
||||
|--------|---------|------|
|
||||
| 铝合金 | 60.00 | 常规机加工费率 |
|
||||
| 铸铝 | 60.00 | 同铝合金 |
|
||||
| 铸铁 | 75.00 | 刀具损耗高,费率上浮 |
|
||||
| 球墨铸铁 | 80.00 | 强度高,费率最高 |
|
||||
|
||||
---
|
||||
|
||||
## 4 幂等性与重跑机制
|
||||
|
||||
`init.sql` 整体可重复执行,无副作用(NF-3):
|
||||
|
||||
1. **建表**:`CREATE TABLE IF NOT EXISTS`。
|
||||
2. **历史去重**:对 SERIAL 表先按业务键删除重复行(保留最小 id)。
|
||||
3. **补唯一约束**:`DO $$ ... ADD CONSTRAINT ... EXCEPTION WHEN duplicate_table` 幂等加约束(uq_machining_cycle / uq_grade_mapping / uq_post_process)。
|
||||
4. **种子写入**:全部 `ON CONFLICT (...) DO NOTHING`。
|
||||
5. **月度价格**:`UNIQUE(internal_code, price_date)` + `ON CONFLICT DO NOTHING`,重跑不翻倍。
|
||||
|
||||
---
|
||||
|
||||
## 5 数据维护约定
|
||||
|
||||
| 场景 | 操作 | 约束 |
|
||||
|------|------|------|
|
||||
| 月度铝价更新 | `material_price_history` 追加新月行 | 核价自动取最新月并留痕 `price_basis_month` |
|
||||
| 新增机加工规格 | `machining_cycle` 增行 | 不改旧行(历史报价可复算) |
|
||||
| 新增吨位 | `die_casting_params` 增行 | 同上 |
|
||||
| 新增物料 | `material_master` 增行 | material_grade 须能命中对照表,否则走回退 |
|
||||
|
||||
> ERP 侧全部为只读基础数据:无 MCP 写工具,数据变更仅通过 SQL/脚本进行。
|
||||
@@ -19,7 +19,6 @@ import asyncpg
|
||||
|
||||
INIT_SQL_PATH = Path(__file__).resolve().parent.parent / "sql" / "init.sql"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="初始化 mcp_auth 库并建表")
|
||||
parser.add_argument("--host", default=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1"))
|
||||
@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS mcp_token (
|
||||
token_plain VARCHAR(128), -- 明文 Token(管理后台二次复制用)
|
||||
token_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
|
||||
client_id VARCHAR(64) NOT NULL, -- 调用方标识(如 trae / partner-a)
|
||||
service_scope VARCHAR(32) NOT NULL, -- 'erp' | 'crm'
|
||||
service_scope VARCHAR(32) NOT NULL, -- 'erp' | 'crm'(服务标识)
|
||||
service_url VARCHAR(255), -- MCP 服务地址(如 http://10.100.154.100:8001/mcp)
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked/expired
|
||||
expires_at TIMESTAMPTZ, -- null = 永不过期
|
||||
description VARCHAR(200), -- 用途说明
|
||||
@@ -56,6 +57,7 @@ CREATE TABLE IF NOT EXISTS admin_user (
|
||||
CREATE TABLE IF NOT EXISTS mcp_service (
|
||||
service_id BIGSERIAL PRIMARY KEY,
|
||||
service_name VARCHAR(64) UNIQUE NOT NULL, -- erp / crm / ...
|
||||
service_url VARCHAR(255) UNIQUE NOT NULL, -- MCP 服务地址(如 http://10.100.154.100:8001/mcp),唯一
|
||||
api_key VARCHAR(128) NOT NULL, -- 明文 API Key(管理后台展示用,MCP 服务用此值)
|
||||
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key),verify-token 校验用
|
||||
description VARCHAR(200),
|
||||
@@ -0,0 +1,33 @@
|
||||
-- ============================================================
|
||||
-- mcp_service 表升级脚本:添加 MCP 服务地址字段(唯一约束)
|
||||
-- 适用:已部署的 mcp_auth 库(幂等可重跑)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 添加 service_url 列(允许 NULL,便于已有数据平滑过渡)
|
||||
ALTER TABLE mcp_service
|
||||
ADD COLUMN IF NOT EXISTS service_url VARCHAR(255);
|
||||
|
||||
-- 2. 为已有记录回填 service_url(按服务名推断默认地址,可按实际环境修改)
|
||||
UPDATE mcp_service
|
||||
SET service_url = 'http://10.100.154.100:' || (CASE service_name
|
||||
WHEN 'erp' THEN '8001'
|
||||
WHEN 'crm' THEN '8002'
|
||||
ELSE '8000' END) || '/mcp'
|
||||
WHERE service_url IS NULL;
|
||||
|
||||
-- 3. 设置为 NOT NULL
|
||||
ALTER TABLE mcp_service
|
||||
ALTER COLUMN service_url SET NOT NULL;
|
||||
|
||||
-- 4. 添加唯一约束(幂等:先检查约束是否存在)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uk_mcp_service_url'
|
||||
AND conrelid = 'mcp_service'::regclass
|
||||
) THEN
|
||||
ALTER TABLE mcp_service
|
||||
ADD CONSTRAINT uk_mcp_service_url UNIQUE (service_url);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- ============================================================
|
||||
-- mcp_token 表升级脚本:添加 MCP 服务地址字段(签发时与服务标识一同保存)
|
||||
-- 适用:已部署的 mcp_auth 库(幂等可重跑)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 添加 service_url 列(可空,历史数据无地址信息)
|
||||
ALTER TABLE mcp_token
|
||||
ADD COLUMN IF NOT EXISTS service_url VARCHAR(255);
|
||||
|
||||
-- 2. 为已有记录按 service_scope 回填默认地址(可按实际环境修改)
|
||||
UPDATE mcp_token
|
||||
SET service_url = 'http://10.100.154.100:' || (CASE service_scope
|
||||
WHEN 'erp' THEN '8001'
|
||||
WHEN 'crm' THEN '8002'
|
||||
ELSE '8000' END) || '/mcp'
|
||||
WHERE service_url IS NULL;
|
||||
Reference in New Issue
Block a user