commit ae992f45a175a1944a2eca37e408b5fe16830cd7 Author: dongsk Date: Sat Aug 8 20:11:23 2026 +0800 init: 汽车零部件智能报价 MCP 服务(ERP + CRM) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ef10a43 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/.DS_Store +**/__pycache__ +**/*.pyc +**/*.pyo +.git +.gitignore +*.md +.pg-init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65c9660 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..acb0821 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ae0e96 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/mcp-for-crm/README.md b/mcp-for-crm/README.md new file mode 100644 index 0000000..cf04187 --- /dev/null +++ b/mcp-for-crm/README.md @@ -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 | 数据库名 | diff --git a/mcp-for-crm/requirements.txt b/mcp-for-crm/requirements.txt new file mode 100644 index 0000000..9d94c11 --- /dev/null +++ b/mcp-for-crm/requirements.txt @@ -0,0 +1,3 @@ +mcp>=1.0.0 +pydantic>=2.0.0 +asyncpg>=0.30.0 diff --git a/mcp-for-crm/sql/init.sql b/mcp-for-crm/sql/init.sql new file mode 100644 index 0000000..a9965e2 --- /dev/null +++ b/mcp-for-crm/sql/init.sql @@ -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; diff --git a/mcp-for-crm/src/db.py b/mcp-for-crm/src/db.py new file mode 100644 index 0000000..66253d6 --- /dev/null +++ b/mcp-for-crm/src/db.py @@ -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 diff --git a/mcp-for-crm/src/seed.py b/mcp-for-crm/src/seed.py new file mode 100644 index 0000000..e3bbd70 --- /dev/null +++ b/mcp-for-crm/src/seed.py @@ -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()) diff --git a/mcp-for-crm/src/server.py b/mcp-for-crm/src/server.py new file mode 100644 index 0000000..2193fa7 --- /dev/null +++ b/mcp-for-crm/src/server.py @@ -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")) diff --git a/mcp-for-erp/README.md b/mcp-for-erp/README.md new file mode 100644 index 0000000..aa6d585 --- /dev/null +++ b/mcp-for-erp/README.md @@ -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 | 数据库名 | diff --git a/mcp-for-erp/requirements.txt b/mcp-for-erp/requirements.txt new file mode 100644 index 0000000..9d94c11 --- /dev/null +++ b/mcp-for-erp/requirements.txt @@ -0,0 +1,3 @@ +mcp>=1.0.0 +pydantic>=2.0.0 +asyncpg>=0.30.0 diff --git a/mcp-for-erp/sql/init.sql b/mcp-for-erp/sql/init.sql new file mode 100644 index 0000000..9541da8 --- /dev/null +++ b/mcp-for-erp/sql/init.sql @@ -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; diff --git a/mcp-for-erp/src/db.py b/mcp-for-erp/src/db.py new file mode 100644 index 0000000..dadbecf --- /dev/null +++ b/mcp-for-erp/src/db.py @@ -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 diff --git a/mcp-for-erp/src/seed.py b/mcp-for-erp/src/seed.py new file mode 100644 index 0000000..316848d --- /dev/null +++ b/mcp-for-erp/src/seed.py @@ -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()) diff --git a/mcp-for-erp/src/server.py b/mcp-for-erp/src/server.py new file mode 100644 index 0000000..9fa0e5d --- /dev/null +++ b/mcp-for-erp/src/server.py @@ -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"))