init: 汽车零部件智能报价 MCP 服务(ERP + CRM)
This commit is contained in:
@@ -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"))
|
||||
Reference in New Issue
Block a user