Compare commits

..
10 Commits
Author SHA1 Message Date
dongsk d71a6f4886 整理后提交 2026-09-02 17:36:11 +08:00
dongsk db94615272 整理后提交 2026-09-02 17:35:48 +08:00
dongsk 674ff09db1 调整文档路径 2026-09-02 17:31:52 +08:00
dongsk 01b3798e02 删除 crm、erp mcp服务 2026-09-02 17:30:48 +08:00
dongsk 91ac4bb4cf 打包部署后端url 2026-09-02 15:06:52 +08:00
dongsk 4654c17801 base 值调整为console 2026-09-02 14:56:51 +08:00
dongsk c74bb0cd6a 调整服务逻辑 2026-09-02 14:43:25 +08:00
dongsk 8c62274e45 调整参数 2026-09-02 09:06:48 +08:00
dongsk 26c982ec74 部署调整 2026-09-01 23:11:14 +08:00
dongsk 1bcbb23281 修改docker部署 2026-09-01 22:46:14 +08:00
66 changed files with 385 additions and 3480 deletions
+28
View File
@@ -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
+2
View File
@@ -37,6 +37,8 @@ node_modules/
dist/
# Vite 缓存
.vite/
# TypeScript 增量构建缓存
*.tsbuildinfo
# npm/pnpm/yarn 锁文件按需保留;如需忽略锁文件取消下行注释
# package-lock.json
# pnpm-lock.yaml
-20
View File
@@ -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
-46
View File
@@ -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
@@ -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
+109
View File
@@ -0,0 +1,109 @@
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;
}
# 静态资源长缓存(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;
}
}
@@ -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>
@@ -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/, ''),
},
},
},
-63
View File
@@ -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
View File
@@ -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"}
-555
View File
@@ -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 服务不做核价计算,只做客户与商务数据管理。
-49
View File
@@ -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 | 数据库名 |
-4
View File
@@ -1,4 +0,0 @@
mcp>=1.0.0
pydantic>=2.0.0
asyncpg>=0.30.0
httpx>=0.27.0
-271
View File
@@ -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
);
-119
View File
@@ -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"
-28
View File
@@ -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
-16
View File
@@ -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())
-543
View File
@@ -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 标记数据受防护 |
| 生产数据 | 不得清理 | 演示/运行留痕作为验收基线 |
-383
View File
@@ -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 — 工具说明文档》。
-60
View File
@@ -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 | 数据库名 |
-4
View File
@@ -1,4 +0,0 @@
mcp>=1.0.0
pydantic>=2.0.0
asyncpg>=0.30.0
httpx>=0.27.0
-234
View File
@@ -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;
-119
View File
@@ -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"
-28
View File
@@ -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
-16
View File
@@ -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())
-353
View File
@@ -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/脚本进行。
-1
View File
@@ -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"))
+3 -1
View File
@@ -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),
+33
View File
@@ -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 $$;
+16
View File
@@ -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;