init: 汽车零部件智能报价 MCP 服务(ERP + CRM)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/.DS_Store
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.pg-init
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# ===== Python =====
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
sdist/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
installs/
|
||||
|
||||
# 测试 / 类型检查 / lint 缓存
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pyre/
|
||||
.pytype/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# 虚拟环境
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.python-version
|
||||
|
||||
# ===== 环境变量与密钥(含数据库密码等,切勿入库)=====
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.env
|
||||
secrets/
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# ===== IDE / 编辑器 =====
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.settings/
|
||||
|
||||
# ===== 操作系统 =====
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# ===== Docker(镜像/卷/构建产物,不入库)=====
|
||||
# 容器运行时数据
|
||||
.docker/
|
||||
# 本地 PostgreSQL 数据与初始化副本(历史遗留)
|
||||
.pg-init/
|
||||
*.sql.gz
|
||||
|
||||
# ===== 日志与临时文件 =====
|
||||
*.log
|
||||
logs/
|
||||
tmp/
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# ===== Jupyter =====
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# ===== 项目本地脚本/快照 =====
|
||||
*.local
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,41 @@
|
||||
# 汽车零部件智能报价 — 两个 MCP 服务编排
|
||||
# 数据库为外部 PostgreSQL(192.168.1.206:5432),不在本编排内
|
||||
# 启动:docker compose up -d
|
||||
# 停止:docker compose down
|
||||
# 日志:docker compose logs -f
|
||||
# 网络模式 host:容器直接共享宿主网络栈,绕开 Docker 桥接 FORWARD 链,
|
||||
# 便于局域网其他机器经 192.168.1.119:8001/8002 直接访问(无需 ports 映射)
|
||||
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=192.168.1.206
|
||||
- ERP_DB_PORT=5432
|
||||
- ERP_DB_USER=postgres
|
||||
- ERP_DB_PASSWORD=digiwin
|
||||
- ERP_DB_NAME=smart_quotation_auto
|
||||
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=192.168.1.206
|
||||
- CRM_DB_PORT=5432
|
||||
- CRM_DB_USER=postgres
|
||||
- CRM_DB_PASSWORD=digiwin
|
||||
- CRM_DB_NAME=smart_quotation_auto
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,48 @@
|
||||
# MCP for CRM — 汽车零部件智能报价 CRM 数据服务
|
||||
|
||||
## 概述
|
||||
|
||||
为汽车零部件(小型阀体外壳)智能报价智能体提供 CRM 数据访问能力,通过 MCP 协议暴露 5 个工具。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: 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) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```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` | 192.168.1.206 | PostgreSQL 主机 |
|
||||
| `CRM_DB_PORT` | 5432 | 端口 |
|
||||
| `CRM_DB_USER` | postgres | 用户名 |
|
||||
| `CRM_DB_PASSWORD` | digiwin | 密码 |
|
||||
| `CRM_DB_NAME` | smart_quotation_auto | 数据库名 |
|
||||
@@ -0,0 +1,3 @@
|
||||
mcp>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
@@ -0,0 +1,251 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
@@ -0,0 +1,28 @@
|
||||
"""PostgreSQL 连接池管理"""
|
||||
|
||||
import asyncpg
|
||||
import os
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("CRM_DB_HOST", "192.168.1.206"),
|
||||
"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
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
MCP for CRM — 汽车零部件智能报价 CRM 数据服务
|
||||
使用 MCPServer 框架,5 个工具:客户查询、询价建档、报价存档、历史报价、商机管理
|
||||
HTTP 模式,支持远程调用
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from mcp.server import MCPServer
|
||||
from db import get_pool
|
||||
|
||||
app = MCPServer(
|
||||
name="mcp-for-crm-auto",
|
||||
description="汽车零部件智能报价 CRM 数据服务",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 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 层级模糊搜索。返回客户信用等级、折扣率、历史订单等。"""
|
||||
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 中创建新的询价记录。关联客户、图纸号、零件号、年用量。客户提交询价后建档使用。"""
|
||||
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。包含模具费、单价、年用量、成本明细。报价生成后存档使用。"""
|
||||
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 时每个询价单仅返回最新版本。"""
|
||||
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 中的商机状态。支持创建新商机或更新已有商机的阶段、金额、概率。报价后跟进使用。"""
|
||||
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)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 启动 HTTP Server
|
||||
# ============================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(app.run_streamable_http_async(host="0.0.0.0", port=8002, streamable_http_path="/mcp"))
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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` | 192.168.1.206 | PostgreSQL 主机 |
|
||||
| `ERP_DB_PORT` | 5432 | 端口 |
|
||||
| `ERP_DB_USER` | postgres | 用户名 |
|
||||
| `ERP_DB_PASSWORD` | digiwin | 密码 |
|
||||
| `ERP_DB_NAME` | smart_quotation_auto | 数据库名 |
|
||||
@@ -0,0 +1,3 @@
|
||||
mcp>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
asyncpg>=0.30.0
|
||||
@@ -0,0 +1,234 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
@@ -0,0 +1,28 @@
|
||||
"""PostgreSQL 连接池管理"""
|
||||
|
||||
import asyncpg
|
||||
import os
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("ERP_DB_HOST", "192.168.1.206"),
|
||||
"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
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
MCP for ERP — 汽车零部件智能报价 ERP 数据服务
|
||||
使用 MCPServer 框架,5 个工具:物料查询、材质对照、压铸参数、机加工估算、成本核算
|
||||
HTTP 模式,支持远程调用
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from mcp.server import MCPServer
|
||||
from db import get_pool
|
||||
|
||||
app = MCPServer(
|
||||
name="mcp-for-erp-auto",
|
||||
description="汽车零部件智能报价 ERP 数据服务",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具 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 物料主数据。支持按物料编码精确查询,或按零件类别、材质、吨位等参数组合查询。返回物料列表,包含体积、重量、吨位等关键参数。"""
|
||||
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 标准体系,返回等效牌号、密度、铝锭参考价。"""
|
||||
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:
|
||||
"""根据物料编码获取压铸参数。返回压铸机吨位、模次节拍、铝水重量、压铸成本。铝水重量 = 体积 × 密度。"""
|
||||
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:
|
||||
"""根据孔清单估算机加工工时。钻孔、攻丝、铰孔、铣面均有固化节拍,逐项累加得到总工时和成本。"""
|
||||
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 = hole.get("spec", "Φ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 = hole.get("spec", "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 = hole.get("spec", "Φ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:
|
||||
"""汇总核价:材料成本(铝水重量×单价)+ 压铸成本 + 机加工成本 + 后处理成本 + 管理费 + 利润。返回完整成本明细和最终报价。"""
|
||||
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:
|
||||
row = await pool.fetchrow("SELECT cycle_time_sec FROM machining_cycle WHERE operation_type='drill' AND spec=$1", hole.get("spec", "Φ8"))
|
||||
if row:
|
||||
total_machining_time_sec += float(row["cycle_time_sec"]) * hole.get("count", 1)
|
||||
|
||||
for hole in tap_holes:
|
||||
row = await pool.fetchrow("SELECT cycle_time_sec FROM machining_cycle WHERE operation_type='tap' AND spec=$1", hole.get("spec", "M8×1.25"))
|
||||
if row:
|
||||
total_machining_time_sec += float(row["cycle_time_sec"]) * hole.get("count", 1)
|
||||
|
||||
for hole in ream_holes:
|
||||
row = await pool.fetchrow("SELECT cycle_time_sec FROM machining_cycle WHERE operation_type='ream' AND spec=$1", hole.get("spec", "Φ8H7"))
|
||||
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"))
|
||||
Reference in New Issue
Block a user