From 1b0cc60236cf77be5e0998a7cefc14f05f6dfdfb Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 16 Apr 2026 13:30:38 +0800 Subject: [PATCH] bugfix --- README.md | 83 ++++ init/data.json | 54 +++ json/funnel_analysis.json | 82 ++++ json/opportunities_edit.json | 37 ++ json/opportunities_list.json | 38 ++ json/predictions_list.json | 11 + json/revenue_prediction.json | 103 +++++ json/sales_stages_list.json | 26 ++ json/stage_change.json | 44 ++ json/stage_history_list.json | 11 + models/opportunities.json | 128 ++++++ models/opportunity_predictions.json | 92 ++++ models/opportunity_stage_history.json | 78 ++++ models/sales_stages.json | 87 ++++ opportunity_management/__init__.py | 32 ++ .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 700 bytes .../opportunity_core.cpython-310.pyc | Bin 0 -> 13297 bytes opportunity_management/core.py | 271 ++++++++++++ opportunity_management/init.py | 16 + opportunity_management/opportunity_core.py | 399 ++++++++++++++++++ pyproject.toml | 20 + setup.py | 22 + sql/opportunity_tables.sql | 67 +++ test_opportunity_module.py | 89 ++++ wwwroot/base.ui | 55 +++ 25 files changed, 1845 insertions(+) create mode 100644 README.md create mode 100644 init/data.json create mode 100644 json/funnel_analysis.json create mode 100644 json/opportunities_edit.json create mode 100644 json/opportunities_list.json create mode 100644 json/predictions_list.json create mode 100644 json/revenue_prediction.json create mode 100644 json/sales_stages_list.json create mode 100644 json/stage_change.json create mode 100644 json/stage_history_list.json create mode 100644 models/opportunities.json create mode 100644 models/opportunity_predictions.json create mode 100644 models/opportunity_stage_history.json create mode 100644 models/sales_stages.json create mode 100644 opportunity_management/__init__.py create mode 100644 opportunity_management/__pycache__/__init__.cpython-310.pyc create mode 100644 opportunity_management/__pycache__/opportunity_core.cpython-310.pyc create mode 100644 opportunity_management/core.py create mode 100644 opportunity_management/init.py create mode 100644 opportunity_management/opportunity_core.py create mode 100644 pyproject.toml create mode 100644 setup.py create mode 100644 sql/opportunity_tables.sql create mode 100644 test_opportunity_module.py create mode 100644 wwwroot/base.ui diff --git a/README.md b/README.md new file mode 100644 index 0000000..698ff98 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# 商机管理模块 (Opportunity Management) + +## 模块概述 +商机管理模块提供完整的销售跟进和商机全生命周期管理功能,支持商机创建、阶段管理、漏斗分析和销售预测。 + +## 功能特性 + +### 2.1 商机管理(销售跟进模块) + +#### 2.1.1 商机全生命周期管理 +- **商机创建**:支持手动录入和线索转化,必填字段包括客户名称、预估金额、销售阶段、预计成交时间 +- **阶段管理**:自定义销售漏斗(初步接洽→需求确认→方案报价→合同谈判→成交),阶段变更需记录原因 + +#### 2.1.2 商机分析 +- **漏斗可视化**:展示各阶段商机数量/金额占比,支持按区域、销售维度筛选 +- **预测功能**:基于历史转化率自动计算预计成交金额,偏差率≤15% + +## 数据库表结构 + +### opportunities (商机表) +- `id`: 商机ID (主键) +- `customer_name`: 客户名称 (必填) +- `estimated_amount`: 预估金额 (必填) +- `current_stage`: 当前销售阶段 (必填) +- `expected_close_date`: 预计成交时间 (必填) +- `source_type`: 来源类型 (manual=手动录入, lead=线索转化) +- `owner_id`: 负责人ID +- `region`: 区域 +- `created_at`: 创建时间 +- `updated_at`: 更新时间 +- `status`: 状态 (active=活跃, won=成交, lost=丢单) + +### sales_stages (销售阶段表) +- `id`: 阶段ID (主键) +- `stage_name`: 阶段名称 +- `stage_order`: 阶段顺序 +- `description`: 阶段描述 +- `conversion_rate`: 历史转化率 +- `is_active`: 是否启用 + +### opportunity_stage_history (商机阶段变更历史表) +- `id`: 历史记录ID (主键) +- `opportunity_id`: 商机ID +- `from_stage`: 原阶段 +- `to_stage`: 目标阶段 +- `change_reason`: 变更原因 (必填) +- `changed_by`: 变更人ID +- `changed_at`: 变更时间 + +### opportunity_predictions (商机预测表) +- `id`: 预测记录ID (主键) +- `opportunity_id`: 商机ID +- `predicted_amount`: 预测金额 +- `confidence_level`: 置信度 +- `prediction_date`: 预测日期 +- `actual_amount`: 实际金额 (成交后更新) +- `deviation_rate`: 偏差率 + +## API接口 + +### 商机管理 +- `create_opportunity()`: 创建商机 +- `update_opportunity_stage()`: 更新商机阶段 + +### 数据分析 +- `get_funnel_analysis()`: 获取销售漏斗分析数据 +- `get_sales_prediction()`: 获取销售预测汇总 + +## 前端界面 +- 商机列表CRUD界面 +- 销售阶段管理界面 +- 销售漏斗可视化图表 +- 销售预测汇总卡片 + +## 安装部署 +1. 将模块目录复制到 `~/repos/` 目录下 +2. 运行主应用的 `build.sh` 脚本,自动处理数据库表创建和前端资源链接 +3. 模块将自动集成到系统中 + +## 依赖要求 +- ahserver >= 1.0.0 +- sqlor-database-module >= 1.0.0 +- bricks-framework >= 1.0.0 \ No newline at end of file diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..66930c7 --- /dev/null +++ b/init/data.json @@ -0,0 +1,54 @@ +{ + "sales_stages": [ + { + "id": "1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p", + "stage_name": "初步接洽", + "stage_order": 1, + "description": "与潜在客户初步接触,了解基本信息", + "conversion_rate": "0.8000", + "created_at": "2026-04-16 10:30:00", + "updated_at": "2026-04-16 10:30:00", + "is_active": "1" + }, + { + "id": "2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q", + "stage_name": "需求确认", + "stage_order": 2, + "description": "深入了解客户需求和痛点", + "conversion_rate": "0.7000", + "created_at": "2026-04-16 10:30:00", + "updated_at": "2026-04-16 10:30:00", + "is_active": "1" + }, + { + "id": "3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r", + "stage_name": "方案报价", + "stage_order": 3, + "description": "提供解决方案和报价", + "conversion_rate": "0.6000", + "created_at": "2026-04-16 10:30:00", + "updated_at": "2026-04-16 10:30:00", + "is_active": "1" + }, + { + "id": "4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s", + "stage_name": "合同谈判", + "stage_order": 4, + "description": "合同条款谈判和细节确认", + "conversion_rate": "0.5000", + "created_at": "2026-04-16 10:30:00", + "updated_at": "2026-04-16 10:30:00", + "is_active": "1" + }, + { + "id": "5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t", + "stage_name": "成交", + "stage_order": 5, + "description": "成功签约成交", + "conversion_rate": "1.0000", + "created_at": "2026-04-16 10:30:00", + "updated_at": "2026-04-16 10:30:00", + "is_active": "1" + } + ] +} \ No newline at end of file diff --git a/json/funnel_analysis.json b/json/funnel_analysis.json new file mode 100644 index 0000000..ec519ea --- /dev/null +++ b/json/funnel_analysis.json @@ -0,0 +1,82 @@ +{ + "name": "funnel_analysis", + "title": "销售漏斗分析", + "type": "page", + "components": [ + { + "type": "container", + "name": "filters", + "title": "筛选条件", + "components": [ + { + "type": "select", + "name": "region", + "label": "区域", + "options": [] + }, + { + "type": "select", + "name": "owner_id", + "label": "销售负责人", + "options": [] + }, + { + "type": "date_range", + "name": "date_range", + "label": "时间范围" + }, + { + "type": "button", + "name": "search", + "label": "查询", + "action": "refresh_funnel_data" + } + ] + }, + { + "type": "chart", + "name": "funnel_chart", + "title": "销售漏斗图", + "chart_type": "funnel", + "data_source": "funnel_analysis_api", + "config": { + "value_field": "total_amount", + "category_field": "sales_stage", + "show_percentage": true + } + }, + { + "type": "chart", + "name": "amount_pie", + "title": "各阶段金额占比", + "chart_type": "pie", + "data_source": "funnel_analysis_api", + "config": { + "value_field": "total_amount", + "category_field": "sales_stage" + } + }, + { + "type": "card", + "name": "summary_stats", + "title": "汇总统计", + "components": [ + { + "type": "statistic", + "name": "total_opportunities", + "label": "商机总数" + }, + { + "type": "statistic", + "name": "total_amount", + "label": "预估总金额" + }, + { + "type": "statistic", + "name": "predicted_revenue", + "label": "预测成交金额" + } + ] + } + ] +} \ No newline at end of file diff --git a/json/opportunities_edit.json b/json/opportunities_edit.json new file mode 100644 index 0000000..0959fd5 --- /dev/null +++ b/json/opportunities_edit.json @@ -0,0 +1,37 @@ +{ + "tblname": "opportunities", + "alias": "opportunities_edit", + "title": "编辑商机", + "params": { + "formfields": { + "exclouded": ["id", "customer_id", "owner_id", "org_id", "created_at", "updated_at"], + "alters": { + "sales_stage": { + "uitype": "code", + "data": [ + {"value": "初步接洽", "text": "初步接洽"}, + {"value": "需求确认", "text": "需求确认"}, + {"value": "方案报价", "text": "方案报价"}, + {"value": "合同谈判", "text": "合同谈判"}, + {"value": "成交", "text": "成交"} + ] + }, + "status": { + "uitype": "code", + "data": [ + {"value": "active", "text": "活跃"}, + {"value": "won", "text": "已成交"}, + {"value": "lost", "text": "已丢失"} + ] + }, + "source": { + "uitype": "code", + "data": [ + {"value": "manual", "text": "手动录入"}, + {"value": "lead_conversion", "text": "线索转化"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/json/opportunities_list.json b/json/opportunities_list.json new file mode 100644 index 0000000..3347266 --- /dev/null +++ b/json/opportunities_list.json @@ -0,0 +1,38 @@ +{ + "tblname": "opportunities", + "alias": "opportunities_list", + "title": "商机列表", + "params": { + "sortby": ["created_at desc"], + "browserfields": { + "exclouded": ["id", "customer_id", "owner_id", "org_id"], + "alters": { + "sales_stage": { + "uitype": "code", + "data": [ + {"value": "初步接洽", "text": "初步接洽"}, + {"value": "需求确认", "text": "需求确认"}, + {"value": "方案报价", "text": "方案报价"}, + {"value": "合同谈判", "text": "合同谈判"}, + {"value": "成交", "text": "成交"} + ] + }, + "status": { + "uitype": "code", + "data": [ + {"value": "active", "text": "活跃"}, + {"value": "won", "text": "已成交"}, + {"value": "lost", "text": "已丢失"} + ] + }, + "source": { + "uitype": "code", + "data": [ + {"value": "manual", "text": "手动录入"}, + {"value": "lead_conversion", "text": "线索转化"} + ] + } + } + } + } +} \ No newline at end of file diff --git a/json/predictions_list.json b/json/predictions_list.json new file mode 100644 index 0000000..c7c03e0 --- /dev/null +++ b/json/predictions_list.json @@ -0,0 +1,11 @@ +{ + "tblname": "opportunity_predictions", + "alias": "predictions_list", + "title": "商机预测记录", + "params": { + "sortby": ["prediction_date desc"], + "browserfields": { + "exclouded": ["id", "opportunity_id"] + } + } +} \ No newline at end of file diff --git a/json/revenue_prediction.json b/json/revenue_prediction.json new file mode 100644 index 0000000..1c7ebfb --- /dev/null +++ b/json/revenue_prediction.json @@ -0,0 +1,103 @@ +{ + "name": "revenue_prediction", + "title": "收入预测", + "type": "page", + "components": [ + { + "type": "container", + "name": "filters", + "title": "预测条件", + "components": [ + { + "type": "select", + "name": "owner_id", + "label": "销售负责人", + "options": [] + }, + { + "type": "select", + "name": "region", + "label": "区域", + "options": [] + }, + { + "type": "select", + "name": "period", + "label": "历史数据周期", + "options": [ + {"value": "last_3_months", "text": "最近3个月"}, + {"value": "last_6_months", "text": "最近6个月"}, + {"value": "last_year", "text": "最近1年"} + ] + }, + { + "type": "button", + "name": "predict", + "label": "生成预测", + "action": "run_prediction" + } + ] + }, + { + "type": "card", + "name": "prediction_results", + "title": "预测结果", + "components": [ + { + "type": "statistic", + "name": "final_prediction", + "label": "最终预测收入", + "format": "currency" + }, + { + "type": "statistic", + "name": "stage_based_prediction", + "label": "阶段概率预测", + "format": "currency" + }, + { + "type": "statistic", + "name": "historical_based_prediction", + "label": "历史转化率预测", + "format": "currency" + }, + { + "type": "progress", + "name": "confidence_level", + "label": "预测置信度", + "value_field": "confidence_level", + "config": { + "high": 90, + "medium": 70, + "low": 50 + } + } + ] + }, + { + "type": "chart", + "name": "historical_conversion", + "title": "历史转化率趋势", + "chart_type": "line", + "data_source": "conversion_rate_analysis_api", + "config": { + "x_field": "month", + "y_field": "conversion_rate", + "show_markers": true + } + }, + { + "type": "table", + "name": "opportunity_details", + "title": "商机明细", + "data_source": "active_opportunities_api", + "columns": [ + {"field": "customer_name", "title": "客户名称"}, + {"field": "estimated_amount", "title": "预估金额", "format": "currency"}, + {"field": "sales_stage", "title": "销售阶段"}, + {"field": "probability", "title": "成交概率", "format": "percentage"}, + {"field": "expected_close_date", "title": "预计成交时间"} + ] + } + ] +} \ No newline at end of file diff --git a/json/sales_stages_list.json b/json/sales_stages_list.json new file mode 100644 index 0000000..cb4157e --- /dev/null +++ b/json/sales_stages_list.json @@ -0,0 +1,26 @@ +{ + "tblname": "sales_stages", + "alias": "sales_stages_list", + "title": "销售阶段管理", + "params": { + "sortby": ["stage_order"], + "browserfields": { + "exclouded": ["id", "updated_at"], + "alters": { + "is_active": { + "uitype": "code", + "data": [ + { + "value": "1", + "text": "启用" + }, + { + "value": "0", + "text": "禁用" + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/json/stage_change.json b/json/stage_change.json new file mode 100644 index 0000000..5c24a1a --- /dev/null +++ b/json/stage_change.json @@ -0,0 +1,44 @@ +{ + "name": "stage_change", + "title": "商机阶段变更", + "type": "form", + "data_source": "opportunity_by_id_api", + "components": [ + { + "type": "display", + "name": "customer_name", + "label": "客户名称" + }, + { + "type": "display", + "name": "current_stage", + "label": "当前阶段" + }, + { + "type": "select", + "name": "new_stage", + "label": "新阶段", + "required": true, + "options": [ + {"value": "初步接洽", "text": "初步接洽"}, + {"value": "需求确认", "text": "需求确认"}, + {"value": "方案报价", "text": "方案报价"}, + {"value": "合同谈判", "text": "合同谈判"}, + {"value": "成交", "text": "成交"} + ] + }, + { + "type": "textarea", + "name": "change_reason", + "label": "变更原因", + "required": true, + "placeholder": "请输入阶段变更的原因..." + }, + { + "type": "button", + "name": "submit", + "label": "确认变更", + "action": "update_opportunity_stage" + } + ] +} \ No newline at end of file diff --git a/json/stage_history_list.json b/json/stage_history_list.json new file mode 100644 index 0000000..8ccf0a7 --- /dev/null +++ b/json/stage_history_list.json @@ -0,0 +1,11 @@ +{ + "tblname": "opportunity_stage_history", + "alias": "stage_history_list", + "title": "阶段变更历史", + "params": { + "sortby": ["changed_at desc"], + "browserfields": { + "exclouded": ["id", "opportunity_id", "changed_by"] + } + } +} \ No newline at end of file diff --git a/models/opportunities.json b/models/opportunities.json new file mode 100644 index 0000000..1a584f2 --- /dev/null +++ b/models/opportunities.json @@ -0,0 +1,128 @@ +{ + "summary": [ + { + "name": "opportunities", + "title": "商机表", + "primary": "id", + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "商机ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "主键 - UUID格式" + }, + { + "name": "customer_name", + "title": "客户名称", + "type": "str", + "length": 255, + "nullable": "no", + "comments": "客户公司或个人名称" + }, + { + "name": "estimated_amount", + "title": "预估金额", + "type": "decimal", + "length": 15, + "dec": 2, + "nullable": "no", + "default": "0.00", + "comments": "预估成交金额" + }, + { + "name": "current_stage", + "title": "当前销售阶段", + "type": "str", + "length": 50, + "nullable": "no", + "comments": "当前所处的销售阶段" + }, + { + "name": "expected_close_date", + "title": "预计成交时间", + "type": "date", + "nullable": "no", + "comments": "预计成交日期" + }, + { + "name": "source_type", + "title": "来源类型", + "type": "str", + "length": 20, + "nullable": "no", + "default": "manual", + "comments": "商机来源:manual=手动录入, lead=线索转化" + }, + { + "name": "owner_id", + "title": "负责人ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "负责该商机的销售人员ID" + }, + { + "name": "region", + "title": "区域", + "type": "str", + "length": 100, + "nullable": "yes", + "comments": "客户所在区域" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "comments": "商机创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "no", + "comments": "最后更新时间" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 20, + "nullable": "no", + "default": "active", + "comments": "商机状态:active=活跃, won=成交, lost=丢单" + } + ], + "indexes": [ + { + "name": "idx_opportunities_customer", + "idxtype": "index", + "idxfields": ["customer_name"] + }, + { + "name": "idx_opportunities_owner", + "idxtype": "index", + "idxfields": ["owner_id"] + }, + { + "name": "idx_opportunities_stage", + "idxtype": "index", + "idxfields": ["current_stage"] + }, + { + "name": "idx_opportunities_region", + "idxtype": "index", + "idxfields": ["region"] + }, + { + "name": "idx_opportunities_status", + "idxtype": "index", + "idxfields": ["status"] + } + ] +} \ No newline at end of file diff --git a/models/opportunity_predictions.json b/models/opportunity_predictions.json new file mode 100644 index 0000000..068dba3 --- /dev/null +++ b/models/opportunity_predictions.json @@ -0,0 +1,92 @@ +{ + "summary": [ + { + "name": "opportunity_predictions", + "title": "商机预测表", + "primary": "id", + "catelog": "indication" + } + ], + "fields": [ + { + "name": "id", + "title": "预测记录ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "主键 - UUID格式" + }, + { + "name": "opportunity_id", + "title": "商机ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "关联的商机ID" + }, + { + "name": "predicted_amount", + "title": "预测金额", + "type": "decimal", + "length": 15, + "dec": 2, + "nullable": "no", + "default": "0.00", + "comments": "基于历史转化率计算的预测成交金额" + }, + { + "name": "confidence_level", + "title": "置信度", + "type": "decimal", + "length": 5, + "dec": 4, + "nullable": "no", + "default": "0.0000", + "comments": "预测的置信度(0-1)" + }, + { + "name": "prediction_date", + "title": "预测日期", + "type": "date", + "nullable": "no", + "comments": "预测生成日期" + }, + { + "name": "actual_amount", + "title": "实际金额", + "type": "decimal", + "length": 15, + "dec": 2, + "nullable": "yes", + "comments": "实际成交金额(成交后更新)" + }, + { + "name": "deviation_rate", + "title": "偏差率", + "type": "decimal", + "length": 5, + "dec": 4, + "nullable": "yes", + "comments": "预测与实际的偏差率" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "comments": "记录创建时间" + } + ], + "indexes": [ + { + "name": "idx_predictions_opportunity", + "idxtype": "unique", + "idxfields": ["opportunity_id", "prediction_date"] + }, + { + "name": "idx_predictions_date", + "idxtype": "index", + "idxfields": ["prediction_date"] + } + ] +} \ No newline at end of file diff --git a/models/opportunity_stage_history.json b/models/opportunity_stage_history.json new file mode 100644 index 0000000..34e1d4b --- /dev/null +++ b/models/opportunity_stage_history.json @@ -0,0 +1,78 @@ +{ + "summary": [ + { + "name": "opportunity_stage_history", + "title": "商机阶段变更历史表", + "primary": "id", + "catelog": "relation" + } + ], + "fields": [ + { + "name": "id", + "title": "历史记录ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "主键 - UUID格式" + }, + { + "name": "opportunity_id", + "title": "商机ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "关联的商机ID" + }, + { + "name": "from_stage", + "title": "原阶段", + "type": "str", + "length": 50, + "nullable": "yes", + "comments": "变更前的阶段" + }, + { + "name": "to_stage", + "title": "目标阶段", + "type": "str", + "length": 50, + "nullable": "no", + "comments": "变更后的阶段" + }, + { + "name": "change_reason", + "title": "变更原因", + "type": "text", + "nullable": "no", + "comments": "阶段变更的原因说明" + }, + { + "name": "changed_by", + "title": "变更人ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "执行变更的用户ID" + }, + { + "name": "changed_at", + "title": "变更时间", + "type": "timestamp", + "nullable": "no", + "comments": "变更时间" + } + ], + "indexes": [ + { + "name": "idx_stage_history_opportunity", + "idxtype": "index", + "idxfields": ["opportunity_id"] + }, + { + "name": "idx_stage_history_changed_by", + "idxtype": "index", + "idxfields": ["changed_by"] + } + ] +} \ No newline at end of file diff --git a/models/sales_stages.json b/models/sales_stages.json new file mode 100644 index 0000000..3edeb79 --- /dev/null +++ b/models/sales_stages.json @@ -0,0 +1,87 @@ +{ + "summary": [ + { + "name": "sales_stages", + "title": "销售阶段表", + "primary": "id", + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "阶段ID", + "type": "str", + "length": 32, + "nullable": "no", + "comments": "主键 - UUID格式" + }, + { + "name": "stage_name", + "title": "阶段名称", + "type": "str", + "length": 100, + "nullable": "no", + "comments": "销售阶段名称" + }, + { + "name": "stage_order", + "title": "阶段顺序", + "type": "long", + "nullable": "no", + "comments": "阶段在销售漏斗中的顺序" + }, + { + "name": "description", + "title": "阶段描述", + "type": "text", + "nullable": "yes", + "comments": "阶段详细描述" + }, + { + "name": "conversion_rate", + "title": "历史转化率", + "type": "decimal", + "length": 5, + "dec": 4, + "nullable": "yes", + "default": "0.0000", + "comments": "该阶段到下一阶段的历史平均转化率" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no", + "comments": "创建时间" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "no", + "comments": "最后更新时间" + }, + { + "name": "is_active", + "title": "是否启用", + "type": "str", + "length": 1, + "nullable": "no", + "default": "1", + "comments": "是否启用:1=启用, 0=禁用" + } + ], + "indexes": [ + { + "name": "idx_sales_stages_order", + "idxtype": "unique", + "idxfields": ["stage_order"] + }, + { + "name": "idx_sales_stages_name", + "idxtype": "unique", + "idxfields": ["stage_name"] + } + ] +} \ No newline at end of file diff --git a/opportunity_management/__init__.py b/opportunity_management/__init__.py new file mode 100644 index 0000000..509cee1 --- /dev/null +++ b/opportunity_management/__init__.py @@ -0,0 +1,32 @@ +""" +商机管理模块 +实现商机全生命周期管理和商机分析功能 +""" + +from .opportunity_core import ( + create_opportunity, + update_opportunity_stage, + get_funnel_analysis, + predict_revenue, + update_opportunity, + delete_opportunity, + get_opportunity_by_id, + list_opportunities +) + +# 版本信息 +__version__ = "1.0.0" +__author__ = "Hermes AI Agent" +__description__ = "Opportunity Management Module with Full Lifecycle and Analytics" + +# 导出所有公共接口 +__all__ = [ + 'create_opportunity', + 'update_opportunity_stage', + 'get_funnel_analysis', + 'predict_revenue', + 'update_opportunity', + 'delete_opportunity', + 'get_opportunity_by_id', + 'list_opportunities' +] \ No newline at end of file diff --git a/opportunity_management/__pycache__/__init__.cpython-310.pyc b/opportunity_management/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fba2a703fc9cf66318b38142a0a98bbdf396bdd4 GIT binary patch literal 700 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 A?EnA( literal 0 HcmV?d00001 diff --git a/opportunity_management/core.py b/opportunity_management/core.py new file mode 100644 index 0000000..d615ca1 --- /dev/null +++ b/opportunity_management/core.py @@ -0,0 +1,271 @@ +from datetime import datetime, date +from decimal import Decimal +from typing import List, Dict, Optional +import uuid + +from ahserver.serverenv import ServerEnv +from appPublic.worker import awaitify +from sqlor.dbp import DBP + + +async def create_opportunity( + customer_name: str, + estimated_amount: float, + current_stage: str, + expected_close_date: str, + source_type: str = "manual", + owner_id: str = None, + region: str = None +) -> Dict: + """创建商机""" + dbp = DBP() + opportunity_id = str(uuid.uuid4()).replace('-', '') + + # 验证阶段是否存在 + stage_exists = await dbp.select_one( + "sales_stages", + {"stage_name": current_stage, "is_active": "1"} + ) + if not stage_exists: + raise ValueError(f"销售阶段 '{current_stage}' 不存在或未启用") + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + opportunity_data = { + "id": opportunity_id, + "customer_name": customer_name, + "estimated_amount": estimated_amount, + "current_stage": current_stage, + "expected_close_date": expected_close_date, + "source_type": source_type, + "owner_id": owner_id or get_current_user_id(), + "region": region, + "created_at": now, + "updated_at": now, + "status": "active" + } + + await dbp.insert("opportunities", opportunity_data) + + # 创建初始预测记录 + await create_prediction_record(opportunity_id, estimated_amount, current_stage) + + return opportunity_data + + +async def update_opportunity_stage( + opportunity_id: str, + new_stage: str, + change_reason: str, + changed_by: str = None +) -> Dict: + """更新商机阶段""" + dbp = DBP() + changed_by = changed_by or get_current_user_id() + + # 获取当前商机信息 + opportunity = await dbp.select_one("opportunities", {"id": opportunity_id}) + if not opportunity: + raise ValueError("商机不存在") + + if opportunity["current_stage"] == new_stage: + raise ValueError("商机已在目标阶段") + + # 验证新阶段是否存在 + new_stage_info = await dbp.select_one( + "sales_stages", + {"stage_name": new_stage, "is_active": "1"} + ) + if not new_stage_info: + raise ValueError(f"销售阶段 '{new_stage}' 不存在或未启用") + + # 记录阶段变更历史 + history_id = str(uuid.uuid4()).replace('-', '') + history_data = { + "id": history_id, + "opportunity_id": opportunity_id, + "from_stage": opportunity["current_stage"], + "to_stage": new_stage, + "change_reason": change_reason, + "changed_by": changed_by, + "changed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + await dbp.insert("opportunity_stage_history", history_data) + + # 更新商机阶段 + await dbp.update( + "opportunities", + {"current_stage": new_stage, "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}, + {"id": opportunity_id} + ) + + # 更新预测记录 + await update_prediction_record(opportunity_id, opportunity["estimated_amount"], new_stage) + + return { + "opportunity_id": opportunity_id, + "from_stage": opportunity["current_stage"], + "to_stage": new_stage, + "history_id": history_id + } + + +async def create_prediction_record( + opportunity_id: str, + estimated_amount: float, + current_stage: str +) -> Dict: + """创建预测记录""" + dbp = DBP() + + # 获取当前阶段的转化率 + stage_info = await dbp.select_one("sales_stages", {"stage_name": current_stage}) + conversion_rate = float(stage_info.get("conversion_rate", "0.0000")) if stage_info else 0.0 + + predicted_amount = estimated_amount * conversion_rate if conversion_rate > 0 else estimated_amount + confidence_level = min(conversion_rate, 1.0) if conversion_rate > 0 else 0.5 + + prediction_id = str(uuid.uuid4()).replace('-', '') + prediction_data = { + "id": prediction_id, + "opportunity_id": opportunity_id, + "predicted_amount": predicted_amount, + "confidence_level": confidence_level, + "prediction_date": date.today().strftime("%Y-%m-%d"), + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + await dbp.insert("opportunity_predictions", prediction_data) + return prediction_data + + +async def update_prediction_record( + opportunity_id: str, + estimated_amount: float, + current_stage: str +) -> Dict: + """更新预测记录""" + dbp = DBP() + + # 获取当前阶段的转化率 + stage_info = await dbp.select_one("sales_stages", {"stage_name": current_stage}) + conversion_rate = float(stage_info.get("conversion_rate", "0.0000")) if stage_info else 0.0 + + predicted_amount = estimated_amount * conversion_rate if conversion_rate > 0 else estimated_amount + confidence_level = min(conversion_rate, 1.0) if conversion_rate > 0 else 0.5 + + # 检查今天是否已有预测记录 + today = date.today().strftime("%Y-%m-%d") + existing_prediction = await dbp.select_one( + "opportunity_predictions", + {"opportunity_id": opportunity_id, "prediction_date": today} + ) + + prediction_data = { + "predicted_amount": predicted_amount, + "confidence_level": confidence_level, + "prediction_date": today, + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + if existing_prediction: + # 更新现有记录 + await dbp.update( + "opportunity_predictions", + prediction_data, + {"id": existing_prediction["id"]} + ) + prediction_data["id"] = existing_prediction["id"] + else: + # 创建新记录 + prediction_id = str(uuid.uuid4()).replace('-', '') + prediction_data["id"] = prediction_id + prediction_data["opportunity_id"] = opportunity_id + await dbp.insert("opportunity_predictions", prediction_data) + + return prediction_data + + +async def get_funnel_analysis( + region: str = None, + owner_id: str = None +) -> List[Dict]: + """获取销售漏斗分析数据""" + dbp = DBP() + + # 构建查询条件 + where_clause = {} + if region: + where_clause["region"] = region + if owner_id: + where_clause["owner_id"] = owner_id + + # 获取所有活跃商机按阶段分组 + funnel_data = await dbp.query(""" + SELECT + o.current_stage, + s.stage_order, + COUNT(*) as opportunity_count, + SUM(o.estimated_amount) as total_amount, + AVG(p.predicted_amount) as avg_predicted_amount + FROM opportunities o + LEFT JOIN sales_stages s ON o.current_stage = s.stage_name + LEFT JOIN opportunity_predictions p ON o.id = p.opportunity_id + AND p.prediction_date = CURDATE() + WHERE o.status = 'active' + """ + (" AND o.region = %(region)s" if region else "") + + (" AND o.owner_id = %(owner_id)s" if owner_id else "") + """ + GROUP BY o.current_stage, s.stage_order + ORDER BY s.stage_order ASC + """, where_clause) + + return funnel_data + + +async def get_sales_prediction( + region: str = None, + owner_id: str = None +) -> Dict: + """获取销售预测汇总""" + dbp = DBP() + + where_clause = {"prediction_date": date.today().strftime("%Y-%m-%d")} + if region: + where_clause["region"] = region + if owner_id: + where_clause["owner_id"] = owner_id + + # 获取今日预测汇总 + prediction_summary = await dbp.query(""" + SELECT + SUM(p.predicted_amount) as total_predicted, + SUM(p.confidence_level) / COUNT(*) as avg_confidence, + COUNT(*) as opportunity_count + FROM opportunity_predictions p + JOIN opportunities o ON p.opportunity_id = o.id + WHERE p.prediction_date = %(prediction_date)s + """ + (" AND o.region = %(region)s" if region else "") + + (" AND o.owner_id = %(owner_id)s" if owner_id else ""), where_clause) + + if prediction_summary: + return prediction_summary[0] + return {"total_predicted": 0, "avg_confidence": 0, "opportunity_count": 0} + + +def get_current_user_id() -> str: + """获取当前用户ID(模拟实现)""" + # 在实际实现中,这里应该从会话或认证信息中获取 + return "current_user_id" + + +# 同步版本函数(用于前端调用) +def sync_create_opportunity(*args, **kwargs): + return create_opportunity(*args, **kwargs) + +def sync_update_opportunity_stage(*args, **kwargs): + return update_opportunity_stage(*args, **kwargs) + +def sync_get_funnel_analysis(*args, **kwargs): + return get_funnel_analysis(*args, **kwargs) + +def sync_get_sales_prediction(*args, **kwargs): + return get_sales_prediction(*args, **kwargs) \ No newline at end of file diff --git a/opportunity_management/init.py b/opportunity_management/init.py new file mode 100644 index 0000000..4a3969e --- /dev/null +++ b/opportunity_management/init.py @@ -0,0 +1,16 @@ +from ahserver.serverenv import ServerEnv +from appPublic.worker import awaitify +from .core import ( + sync_create_opportunity, + sync_update_opportunity_stage, + sync_get_funnel_analysis, + sync_get_sales_prediction +) + + +def load_opportunity_management(): + env = ServerEnv() + env.create_opportunity = awaitify(sync_create_opportunity) + env.update_opportunity_stage = awaitify(sync_update_opportunity_stage) + env.get_funnel_analysis = awaitify(sync_get_funnel_analysis) + env.get_sales_prediction = awaitify(sync_get_sales_prediction) \ No newline at end of file diff --git a/opportunity_management/opportunity_core.py b/opportunity_management/opportunity_core.py new file mode 100644 index 0000000..ce4023f --- /dev/null +++ b/opportunity_management/opportunity_core.py @@ -0,0 +1,399 @@ +""" +商机管理模块 - 核心业务逻辑 +实现商机全生命周期管理和商机分析功能 +""" + +import os +import json +import uuid +from datetime import datetime, date +from typing import List, Dict, Optional, Tuple +from appPublic.jsonconfig import getConfig +from appPublic.worker import Worker +from sqlor.dbp import getDBP + + +class OpportunityManager: + def __init__(self): + self.config = getConfig() + self.worker = Worker() + + async def get_db_connection(self, org_id: str): + """获取数据库连接""" + dbp = await getDBP(org_id) + return dbp + + async def create_opportunity(self, opportunity_data: Dict, user_id: str, org_id: str) -> str: + """创建商机""" + # 验证必填字段 + required_fields = ['customer_name', 'estimated_amount', 'sales_stage', 'expected_close_date'] + for field in required_fields: + if not opportunity_data.get(field): + raise ValueError(f"缺少必填字段: {field}") + + opportunity_id = str(uuid.uuid4()).replace('-', '') + dbp = await self.get_db_connection(org_id) + + # 插入商机数据 + sql = """ + INSERT INTO opportunities ( + id, customer_name, customer_id, estimated_amount, sales_stage, + expected_close_date, source, description, owner_id, org_id, + status, created_at, updated_at, probability, next_action_date, + next_action_description, tags + ) VALUES ( + %(id)s, %(customer_name)s, %(customer_id)s, %(estimated_amount)s, %(sales_stage)s, + %(expected_close_date)s, %(source)s, %(description)s, %(owner_id)s, %(org_id)s, + %(status)s, NOW(), NOW(), %(probability)s, %(next_action_date)s, + %(next_action_description)s, %(tags)s + ) + """ + + params = { + 'id': opportunity_id, + 'customer_name': opportunity_data['customer_name'], + 'customer_id': opportunity_data.get('customer_id'), + 'estimated_amount': opportunity_data['estimated_amount'], + 'sales_stage': opportunity_data['sales_stage'], + 'expected_close_date': opportunity_data['expected_close_date'], + 'source': opportunity_data.get('source', 'manual'), # manual 或 lead_conversion + 'description': opportunity_data.get('description'), + 'owner_id': user_id, + 'org_id': org_id, + 'status': opportunity_data.get('status', 'active'), + 'probability': opportunity_data.get('probability', self._calculate_probability(opportunity_data['sales_stage'])), + 'next_action_date': opportunity_data.get('next_action_date'), + 'next_action_description': opportunity_data.get('next_action_description'), + 'tags': opportunity_data.get('tags') + } + + await dbp.doTransaction([{'sql': sql, 'params': params}]) + return opportunity_id + + def _calculate_probability(self, sales_stage: str) -> float: + """根据销售阶段计算成交概率""" + stage_probabilities = { + '初步接洽': 0.1, + '需求确认': 0.3, + '方案报价': 0.5, + '合同谈判': 0.7, + '成交': 1.0 + } + return stage_probabilities.get(sales_stage, 0.1) + + async def update_opportunity_stage(self, opportunity_id: str, new_stage: str, + change_reason: str, user_id: str, org_id: str) -> bool: + """更新商机阶段""" + dbp = await self.get_db_connection(org_id) + + # 获取当前商机信息 + current_opportunity = await dbp.select_one("opportunities", {"id": opportunity_id, "org_id": org_id}) + if not current_opportunity: + raise ValueError("商机不存在") + + old_stage = current_opportunity['sales_stage'] + + # 更新商机阶段和概率 + new_probability = self._calculate_probability(new_stage) + update_data = { + 'sales_stage': new_stage, + 'probability': new_probability, + 'updated_at': datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + # 如果阶段变为成交,更新状态 + if new_stage == '成交': + update_data['status'] = 'won' + update_data['actual_close_date'] = datetime.now().strftime("%Y-%m-%d") + + # 如果阶段回退,可能需要更新状态 + elif old_stage == '成交' and new_stage != '成交': + update_data['status'] = 'active' + update_data['actual_close_date'] = None + + result = await dbp.update("opportunities", update_data, {"id": opportunity_id, "org_id": org_id}) + + # 记录阶段变更历史 + if old_stage != new_stage: + await self._record_stage_change(opportunity_id, old_stage, new_stage, change_reason, user_id, org_id) + + return result.rowcount > 0 + + async def _record_stage_change(self, opportunity_id: str, old_stage: str, + new_stage: str, change_reason: str, user_id: str, org_id: str): + """记录阶段变更历史""" + history_id = str(uuid.uuid4()).replace('-', '') + dbp = await self.get_db_connection(org_id) + + history_data = { + 'id': history_id, + 'opportunity_id': opportunity_id, + 'old_stage': old_stage, + 'new_stage': new_stage, + 'change_reason': change_reason, + 'changed_by': user_id, + 'created_at': datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + await dbp.insert("opportunity_stage_history", history_data) + + async def get_funnel_analysis(self, org_id: str, filters: Optional[Dict] = None) -> Dict: + """获取销售漏斗分析数据""" + dbp = await self.get_db_connection(org_id) + + # 构建查询条件 + where_clauses = ["o.org_id = %(org_id)s", "o.status = 'active'"] + params = {'org_id': org_id} + + if filters: + if filters.get('region'): + where_clauses.append("c.region = %(region)s") + params['region'] = filters['region'] + if filters.get('owner_id'): + where_clauses.append("o.owner_id = %(owner_id)s") + params['owner_id'] = filters['owner_id'] + if filters.get('date_range'): + start_date, end_date = filters['date_range'] + where_clauses.append("o.created_at BETWEEN %(start_date)s AND %(end_date)s") + params['start_date'] = start_date + params['end_date'] = end_date + + where_sql = " AND ".join(where_clauses) + + # 获取各阶段商机数量和金额 + funnel_sql = f""" + SELECT + o.sales_stage, + COUNT(*) as opportunity_count, + SUM(o.estimated_amount) as total_amount, + AVG(o.probability) as avg_probability + FROM opportunities o + LEFT JOIN customers c ON o.customer_id = c.id + WHERE {where_sql} + GROUP BY o.sales_stage + ORDER BY + CASE o.sales_stage + WHEN '初步接洽' THEN 1 + WHEN '需求确认' THEN 2 + WHEN '方案报价' THEN 3 + WHEN '合同谈判' THEN 4 + WHEN '成交' THEN 5 + ELSE 99 + END + """ + + funnel_data = await dbp.doQuery(funnel_sql, params) + + # 计算预测成交金额 + predicted_revenue = 0 + for item in funnel_data: + predicted_revenue += float(item['total_amount']) * float(item['avg_probability']) + + return { + 'funnel_data': funnel_data, + 'predicted_revenue': predicted_revenue, + 'total_opportunities': sum(item['opportunity_count'] for item in funnel_data), + 'total_estimated_amount': sum(float(item['total_amount']) for item in funnel_data) + } + + async def get_conversion_rate_analysis(self, org_id: str, period: str = 'last_6_months') -> Dict: + """获取转化率分析数据(用于预测功能)""" + dbp = await self.get_db_connection(org_id) + + # 确定时间范围 + if period == 'last_6_months': + start_date = (date.today().replace(day=1) - timedelta(days=180)).strftime("%Y-%m-01") + elif period == 'last_year': + start_date = (date.today().replace(day=1) - timedelta(days=365)).strftime("%Y-%m-01") + else: + start_date = (date.today() - timedelta(days=90)).strftime("%Y-%m-%d") + + # 获取历史转化数据 + conversion_sql = """ + SELECT + DATE_FORMAT(created_at, '%%Y-%%m') as month, + COUNT(*) as total_opportunities, + SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_opportunities, + AVG(CASE WHEN status = 'won' THEN estimated_amount ELSE 0 END) as avg_won_amount + FROM opportunities + WHERE org_id = %(org_id)s + AND created_at >= %(start_date)s + GROUP BY DATE_FORMAT(created_at, '%%Y-%%m') + ORDER BY month + """ + + conversion_data = await dbp.doQuery(conversion_sql, {'org_id': org_id, 'start_date': start_date}) + + # 计算整体转化率 + total_opps = sum(item['total_opportunities'] for item in conversion_data) + total_won = sum(item['won_opportunities'] for item in conversion_data) + overall_conversion_rate = total_won / total_opps if total_opps > 0 else 0 + + return { + 'conversion_data': conversion_data, + 'overall_conversion_rate': overall_conversion_rate, + 'period': period, + 'start_date': start_date + } + + async def predict_revenue(self, org_id: str, filters: Optional[Dict] = None) -> Dict: + """预测收入(基于历史转化率)""" + # 获取当前活跃商机 + dbp = await self.get_db_connection(org_id) + + where_clauses = ["org_id = %(org_id)s", "status = 'active'"] + params = {'org_id': org_id} + + if filters: + if filters.get('owner_id'): + where_clauses.append("owner_id = %(owner_id)s") + params['owner_id'] = filters['owner_id'] + if filters.get('region'): + # 需要关联客户表 + pass + + where_sql = " AND ".join(where_clauses) + + opportunities_sql = f""" + SELECT id, estimated_amount, probability, sales_stage + FROM opportunities + WHERE {where_sql} + """ + + current_opportunities = await dbp.doQuery(opportunities_sql, params) + + # 获取历史转化率 + conversion_analysis = await self.get_conversion_rate_analysis(org_id) + historical_conversion_rate = conversion_analysis['overall_conversion_rate'] + + # 计算预测收入 + stage_based_prediction = sum( + float(opp['estimated_amount']) * float(opp['probability']) + for opp in current_opportunities + ) + + historical_based_prediction = sum( + float(opp['estimated_amount']) * historical_conversion_rate + for opp in current_opportunities + ) + + # 使用加权平均(70%阶段概率 + 30%历史转化率) + final_prediction = stage_based_prediction * 0.7 + historical_based_prediction * 0.3 + + return { + 'stage_based_prediction': stage_based_prediction, + 'historical_based_prediction': historical_based_prediction, + 'final_prediction': final_prediction, + 'opportunity_count': len(current_opportunities), + 'total_estimated_amount': sum(float(opp['estimated_amount']) for opp in current_opportunities), + 'confidence_level': 'high' if len(current_opportunities) > 10 else 'medium' + } + + # 基础CRUD操作 + async def update_opportunity(self, opportunity_id: str, opportunity_data: Dict, org_id: str) -> bool: + """更新商机""" + dbp = await self.get_db_connection(org_id) + opportunity_data['updated_at'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + result = await dbp.update("opportunities", opportunity_data, {"id": opportunity_id, "org_id": org_id}) + return result.rowcount > 0 + + async def delete_opportunity(self, opportunity_id: str, org_id: str) -> bool: + """删除商机(软删除)""" + dbp = await self.get_db_connection(org_id) + update_data = { + 'status': 'deleted', + 'updated_at': datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + result = await dbp.update("opportunities", update_data, {"id": opportunity_id, "org_id": org_id}) + return result.rowcount > 0 + + async def get_opportunity_by_id(self, opportunity_id: str, org_id: str) -> Optional[Dict]: + """根据ID获取商机""" + dbp = await self.get_db_connection(org_id) + sql = "SELECT * FROM opportunities WHERE id = %(id)s AND org_id = %(org_id)s AND status != 'deleted'" + result = await dbp.doQuery(sql, {'id': opportunity_id, 'org_id': org_id}) + return result[0] if result else None + + async def list_opportunities(self, org_id: str, filters: Optional[Dict] = None, + page: int = 1, page_size: int = 20) -> Tuple[List[Dict], int]: + """列出商机""" + dbp = await self.get_db_connection(org_id) + + # 构建查询条件 + where_clauses = ["o.org_id = %(org_id)s", "o.status != 'deleted'"] + params = {'org_id': org_id} + + if filters: + if filters.get('customer_name'): + where_clauses.append("o.customer_name LIKE %(customer_name)s") + params['customer_name'] = f"%{filters['customer_name']}%" + if filters.get('sales_stage'): + where_clauses.append("o.sales_stage = %(sales_stage)s") + params['sales_stage'] = filters['sales_stage'] + if filters.get('owner_id'): + where_clauses.append("o.owner_id = %(owner_id)s") + params['owner_id'] = filters['owner_id'] + if filters.get('status'): + where_clauses.append("o.status = %(status)s") + params['status'] = filters['status'] + if filters.get('expected_close_date_from'): + where_clauses.append("o.expected_close_date >= %(expected_close_date_from)s") + params['expected_close_date_from'] = filters['expected_close_date_from'] + if filters.get('expected_close_date_to'): + where_clauses.append("o.expected_close_date <= %(expected_close_date_to)s") + params['expected_close_date_to'] = filters['expected_close_date_to'] + + where_sql = " AND ".join(where_clauses) + + # 获取总数 + count_sql = f"SELECT COUNT(*) as total FROM opportunities o WHERE {where_sql}" + count_result = await dbp.doQuery(count_sql, params) + total = count_result[0]['total'] if count_result else 0 + + # 获取分页数据 + offset = (page - 1) * page_size + data_sql = f""" + SELECT o.*, u.username as owner_name + FROM opportunities o + LEFT JOIN users u ON o.owner_id = u.id + WHERE {where_sql} + ORDER BY o.created_at DESC + LIMIT %(limit)s OFFSET %(offset)s + """ + params['limit'] = page_size + params['offset'] = offset + + data_result = await dbp.doQuery(data_sql, params) + return data_result, total + + +# 全局实例 +opportunity_manager = OpportunityManager() + +# 导出函数 +async def create_opportunity(opportunity_data: Dict, user_id: str, org_id: str) -> str: + return await opportunity_manager.create_opportunity(opportunity_data, user_id, org_id) + +async def update_opportunity_stage(opportunity_id: str, new_stage: str, + change_reason: str, user_id: str, org_id: str) -> bool: + return await opportunity_manager.update_opportunity_stage(opportunity_id, new_stage, change_reason, user_id, org_id) + +async def get_funnel_analysis(org_id: str, filters: Optional[Dict] = None) -> Dict: + return await opportunity_manager.get_funnel_analysis(org_id, filters) + +async def predict_revenue(org_id: str, filters: Optional[Dict] = None) -> Dict: + return await opportunity_manager.predict_revenue(org_id, filters) + +# 基础CRUD接口 +async def update_opportunity(opportunity_id: str, opportunity_data: Dict, org_id: str) -> bool: + return await opportunity_manager.update_opportunity(opportunity_id, opportunity_data, org_id) + +async def delete_opportunity(opportunity_id: str, org_id: str) -> bool: + return await opportunity_manager.delete_opportunity(opportunity_id, org_id) + +async def get_opportunity_by_id(opportunity_id: str, org_id: str) -> Optional[Dict]: + return await opportunity_manager.get_opportunity_by_id(opportunity_id, org_id) + +async def list_opportunities(org_id: str, filters: Optional[Dict] = None, + page: int = 1, page_size: int = 20) -> Tuple[List[Dict], int]: + return await opportunity_manager.list_opportunities(org_id, filters, page, page_size) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..94533ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "opportunity_management" +version = "1.0.0" +description = "商机管理模块 - 提供销售跟进和商机全生命周期管理功能" +authors = [{name = "Hermes Agent", email = "hermes@example.com"}] +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "ahserver", + "sqlor-database-module", + "bricks-framework" +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["opportunity_management*"] \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ba91eee --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +from setuptools import setup, find_packages + +setup( + name="opportunity_management", + version="1.0.0", + description="Opportunity Management Module with Full Lifecycle and Analytics", + author="Hermes AI Agent", + packages=find_packages(), + install_requires=[ + "appPublic>=1.0.0", + "sqlor-database-module>=1.0.0" + ], + python_requires=">=3.8", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + ], +) \ No newline at end of file diff --git a/sql/opportunity_tables.sql b/sql/opportunity_tables.sql new file mode 100644 index 0000000..a47a4f4 --- /dev/null +++ b/sql/opportunity_tables.sql @@ -0,0 +1,67 @@ +-- 商机管理模块数据库表结构 + +-- 1. 商机表 (opportunities) +CREATE TABLE IF NOT EXISTS opportunities ( + id VARCHAR(64) PRIMARY KEY, + customer_name VARCHAR(255) NOT NULL COMMENT '客户名称', + customer_id VARCHAR(64) COMMENT '客户ID(关联客户管理模块)', + estimated_amount DECIMAL(15,2) NOT NULL COMMENT '预估金额', + sales_stage VARCHAR(64) NOT NULL COMMENT '销售阶段', + expected_close_date DATE NOT NULL COMMENT '预计成交时间', + actual_close_date DATE COMMENT '实际成交时间', + source VARCHAR(32) DEFAULT 'manual' COMMENT '来源: manual/lead_conversion', + description TEXT COMMENT '描述', + owner_id VARCHAR(64) NOT NULL COMMENT '负责人ID', + org_id VARCHAR(64) NOT NULL COMMENT '组织ID', + status VARCHAR(32) DEFAULT 'active' COMMENT '状态: active/won/lost/deleted', + probability DECIMAL(5,4) DEFAULT 0.1000 COMMENT '成交概率', + next_action_date DATE COMMENT '下次行动日期', + next_action_description VARCHAR(255) COMMENT '下次行动描述', + tags VARCHAR(255) COMMENT '标签', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_customer_id (customer_id), + INDEX idx_sales_stage (sales_stage), + INDEX idx_owner_id (owner_id), + INDEX idx_org_id (org_id), + INDEX idx_status (status), + INDEX idx_expected_close_date (expected_close_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. 商机阶段变更历史表 (opportunity_stage_history) +CREATE TABLE IF NOT EXISTS opportunity_stage_history ( + id VARCHAR(64) PRIMARY KEY, + opportunity_id VARCHAR(64) NOT NULL, + old_stage VARCHAR(64) NOT NULL COMMENT '原阶段', + new_stage VARCHAR(64) NOT NULL COMMENT '新阶段', + change_reason TEXT NOT NULL COMMENT '变更原因', + changed_by VARCHAR(64) NOT NULL COMMENT '变更人ID', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_opportunity_id (opportunity_id), + INDEX idx_changed_by (changed_by), + FOREIGN KEY (opportunity_id) REFERENCES opportunities(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. 销售漏斗配置表 (sales_funnel_config) +CREATE TABLE IF NOT EXISTS sales_funnel_config ( + id VARCHAR(64) PRIMARY KEY, + org_id VARCHAR(64) NOT NULL, + stage_name VARCHAR(64) NOT NULL COMMENT '阶段名称', + stage_order INT NOT NULL COMMENT '阶段顺序', + default_probability DECIMAL(5,4) NOT NULL COMMENT '默认成交概率', + color_code VARCHAR(16) COMMENT '颜色代码', + is_active TINYINT(1) DEFAULT 1 COMMENT '是否激活', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_org_stage (org_id, stage_name), + INDEX idx_org_id (org_id), + INDEX idx_stage_order (stage_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 插入默认销售漏斗配置 +INSERT IGNORE INTO sales_funnel_config (id, org_id, stage_name, stage_order, default_probability, color_code) VALUES +(REPLACE(UUID(), '-', ''), 'default', '初步接洽', 1, 0.1000, '#FF6B6B'), +(REPLACE(UUID(), '-', ''), 'default', '需求确认', 2, 0.3000, '#4ECDC4'), +(REPLACE(UUID(), '-', ''), 'default', '方案报价', 3, 0.5000, '#45B7D1'), +(REPLACE(UUID(), '-', ''), 'default', '合同谈判', 4, 0.7000, '#96CEB4'), +(REPLACE(UUID(), '-', ''), 'default', '成交', 5, 1.0000, '#FFEAA7'); \ No newline at end of file diff --git a/test_opportunity_module.py b/test_opportunity_module.py new file mode 100644 index 0000000..6b4ac0a --- /dev/null +++ b/test_opportunity_module.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Test script for Opportunity Management Module +""" + +import asyncio +import sys +import os + +# Add the module to Python path +sys.path.insert(0, os.path.expanduser('~/repos/opportunity_management')) + +from opportunity_management import create_opportunity, get_opportunity_by_id, list_opportunities + + +async def test_opportunity_creation(): + """Test opportunity creation""" + print("Testing opportunity creation...") + + # Test data + opportunity_data = { + 'customer_name': 'Test Customer', + 'estimated_amount': 100000.00, + 'sales_stage': '初步接洽', + 'expected_close_date': '2026-12-31', + 'description': 'Test opportunity created by automated test', + 'source': 'manual' + } + + try: + # Create opportunity (using dummy org_id and user_id for testing) + opportunity_id = await create_opportunity(opportunity_data, 'test_user', 'test_org') + print(f"✓ Opportunity created successfully: {opportunity_id}") + + # Retrieve the created opportunity + opportunity = await get_opportunity_by_id(opportunity_id, 'test_org') + if opportunity: + print(f"✓ Opportunity retrieved successfully") + print(f" Customer: {opportunity['customer_name']}") + print(f" Amount: {opportunity['estimated_amount']}") + print(f" Stage: {opportunity['sales_stage']}") + else: + print("✗ Failed to retrieve opportunity") + + return True + + except Exception as e: + print(f"✗ Error creating opportunity: {e}") + return False + + +async def test_opportunity_listing(): + """Test opportunity listing""" + print("\nTesting opportunity listing...") + + try: + opportunities, total = await list_opportunities('test_org', page=1, page_size=10) + print(f"✓ Retrieved {len(opportunities)} opportunities out of {total} total") + return True + + except Exception as e: + print(f"✗ Error listing opportunities: {e}") + return False + + +async def main(): + """Main test function""" + print("Opportunity Management Module - Integration Test") + print("=" * 50) + + success = True + + # Test opportunity creation + success &= await test_opportunity_creation() + + # Test opportunity listing + success &= await test_opportunity_listing() + + print("\n" + "=" * 50) + if success: + print("✓ All tests passed! Module is working correctly.") + else: + print("✗ Some tests failed. Please check the implementation.") + + return success + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/wwwroot/base.ui b/wwwroot/base.ui new file mode 100644 index 0000000..e5883ca --- /dev/null +++ b/wwwroot/base.ui @@ -0,0 +1,55 @@ +{ + "widgettype": "TabPanel", + "options": { + "title": "商机管理" + }, + "subwidgets": [ + { + "widgettype": "CRUD", + "options": { + "title": "商机列表", + "url": "{{entire_url(opportunities_list)}}" + } + }, + { + "widgettype": "CRUD", + "options": { + "title": "销售阶段", + "url": "{{entire_url(sales_stages_list)}}" + } + }, + { + "widgettype": "Panel", + "options": { + "title": "销售漏斗分析" + }, + "subwidgets": [ + { + "widgettype": "Chart", + "options": { + "title": "漏斗可视化", + "chartType": "funnel", + "dataSource": "get_funnel_analysis", + "refreshInterval": 30000 + } + } + ] + }, + { + "widgettype": "Panel", + "options": { + "title": "销售预测" + }, + "subwidgets": [ + { + "widgettype": "Card", + "options": { + "title": "今日预测汇总", + "dataSource": "get_sales_prediction", + "fields": ["total_predicted", "avg_confidence", "opportunity_count"] + } + } + ] + } + ] +} \ No newline at end of file