SPMS项目实现过程博客
从零构建「智造中枢 SPMS」—— 一个销售生产一体化管理系统的诞生
这不是一篇"完美架构"的复盘,而是一份踩坑记录与技术决策的流水账。
项目源码基于 Flask + SQLite + 飞书机器人,覆盖销售、生产、物料、发货全链路。
目录
- 项目缘起:为什么会做这个系统
- 需求梳理:5 大模块 18 张表
- 技术选型:为什么是 Flask + SQLite
- 数据库设计:状态机驱动的闭环
- 后端架构:三层分离 + 双路由注册
- 前端实现:一个 Bootstrap 打天下
- 飞书集成:从回调到双机器人的演进
- 进程管理:一键启动的调度器
- 踩坑实录:5 个值得记录的教训
- 总结与展望
1. 项目缘起:为什么会做这个系统
制造业中小型工厂的常见痛点:销售接了单,生产不知道;生产做完了,仓库不知道;仓库发货了,财务不知道。各部门靠 Excel 和微信群沟通,信息断层导致了大量的沟通成本和延期交付。
「智造中枢 SPMS(Sales Production Management System)」就是为解决这个问题而生的。核心目标很简单:
销-产-存-发,每个环节的状态对上下游透明可见。
系统定位为内部管理系统,而非 SaaS 产品。交互方式兼顾 Web 页面和飞书群聊机器人,让车间主任不用打开网页也能完成排产、报工操作。
2. 需求梳理:5 大模块 18 张表
需求来自制造业内部流程——从订单录入到发货的全链路。拆解为以下模块:
┌─────────────────────────────────────────────────┐
│ 仪表盘 │
│ (订单/工单/库存/发货数据汇总) │
├──────────┬──────────┬───────────┬───────────────┤
│ 销售订货 │ 生产计划 │ 物料管控 │ 销售发货 │
│ ─────── │ ─────── │ ──────── │ ─────── │
│ 订单管理 │ 工单管理 │ 发料/退料 │ 发货单管理 │
│ 订单评审 │ 工序排程 │ 完工入库 │ 财务审核 │
│ 订单变更 │ 日计划 │ BOM管理 │ OQC检验 │
│ 预测订单 │ 进度跟踪 │ 库存管理 │ 发货确认 │
│ │ │ 在线仓管理 │ │
├──────────┴──────────┴───────────┴───────────────┤
│ 系统管理 │
│ (用户/角色/工作流审批记录) │
└─────────────────────────────────────────────────┘
经过梳理,最终产出 18 张数据表:
| 模块 | 表名 | 用途 |
|---|---|---|
| 销售 | t_sales_order |
销售订单主表 |
| 销售 | t_sales_order_detail |
订单明细(行项目) |
| 销售 | t_order_review |
多部门评审记录 |
| 销售 | t_order_change |
订单变更历史 |
| 销售 | t_forecast_order |
月度预测订单 |
| 生产 | t_work_order |
生产工单主表 |
| 生产 | t_production_schedule |
工序排程 |
| 生产 | t_daily_plan |
日生产计划 |
| 生产 | t_plan_tracking |
产量跟踪 |
| 物料 | t_material_issue |
发料记录 |
| 物料 | t_material_return |
退料记录 |
| 物料 | t_material_supplement |
补料记录 |
| 物料 | t_online_warehouse |
在线仓(产线物料) |
| 物料 | t_finish_inbound |
完工入库 |
| 物料 | t_bom |
物料清单 |
| 物料 | t_bom_detail |
BOM 明细 |
| 物料 | t_inventory |
库存主数据 |
| 发货 | t_delivery_order |
发货单 |
| 系统 | t_user |
用户 |
| 系统 | t_role |
角色 |
| 系统 | t_workflow |
审批流记录 |
3. 技术选型:为什么是 Flask + SQLite
选型原则
- 快速开发:原型到可用版本越快越好
- 单机部署:内部使用,不需要分布式
- 运维零成本:不需要装数据库服务器、不需要配 Nginx
最终技术栈
| 层级 | 技术 | 理由 |
|---|---|---|
| Web 框架 | Flask 3.1 | 轻量、路由清晰、Jinja2 模板顺手 |
| ORM | Flask-SQLAlchemy 3.1 | 模型即文档,关系映射简洁 |
| 数据库 | SQLite 3 | 零配置、单文件、备份=复制文件 |
| 前端 | Bootstrap 5.3 | CDN 引入、组件丰富、响应式 |
| 图表 | Chart.js 4.4 | 仪表盘柱状图+环形图 |
| 飞书 SDK | lark-oapi ≥1.4.0 | Python 原生支持 WebSocket 长连接 |
为什么不用 Django?
Django 的 Admin 和 ORM 很好,但太重。这个项目的核心是业务流程而非 CRUD,Flask 的路由灵活度更适合快速迭代。
为什么不用 MySQL?
内部使用场景,单机 SQLite 完全扛得住。并发写入少(订单工单操作频率低),SQLite 在 WAL 模式下的读写性能足够。备份只需复制的 spms.db 文件。
4. 数据库设计:状态机驱动的闭环
核心设计思路
不是简单的 CRUD,而是状态流转。 订单、工单、发货单三套各自独立的状态机,通过外键关联形成闭环。
订单状态流转
草稿(0) → 已确认(1) → 生产中(2) → 已完工(3) → 已发货(4)
↓
评审中(1) → 通过(2) / 拒绝(3)
工单状态流转
待排产(0) → 已排产(1) → 生产中(2) → 已完工(3) → 已结案(4)
↑ ↑
(排产时记录) (报工自动计算完工率)
发货状态流转
待审核(0) → 已审核(1) → 已备货(2) → 已发货(3)
↑ ↑
(财务审批) (自动更新关联订单状态)
外键关系设计
SalesOrder ──1:N── SalesOrderDetail (订单明细)
SalesOrder ──1:N── OrderReview (订单评审)
SalesOrder ──1:N── OrderChange (订单变更)
SalesOrder ──1:N── WorkOrder (一个订单可拆多个工单)
SalesOrder ──1:N── DeliveryOrder (一个订单可分批发货)
WorkOrder ──1:N── ProductionSchedule (工序排程)
WorkOrder ──1:N── DailyPlan (日计划)
WorkOrder ──1:N── PlanTracking (进度跟踪)
WorkOrder ──1:N── MaterialIssue (发料)
WorkOrder ──1:N── MaterialReturn (退料)
WorkOrder ──1:N── MaterialSupplement (补料)
WorkOrder ──1:N── FinishInbound (完工入库)
Bom ──1:N── BomDetail (BOM 明细)
DeliveryOrder ──1:N── DeliveryDetail (发货明细)
关键决策:级联删除
所有 detail 子表设置了 cascade='all, delete-orphan',删除主记录自动清理明细。但 MaterialIssue/Return/Supplement/FinishInbound 不设置 cascade,因为它们是业务凭证,不能因工单删除而消失——在飞书机器人的 delete_mo 函数中增加了前置校验:
# 如果工单有物料流转记录,拒绝删除
if has_material or has_inbound:
return '⚠️ 该工单已有物料/入库记录,无法直接删除'
5. 后端架构:三层分离 + 双路由注册
目录结构
spms/
├── app.py # 应用工厂 create_app()
├── config.py # 配置:密钥、数据库URI、飞书凭证
├── models/ # 数据模型层(18个模型类)
├── blueprints/ # 路由控制器层(7个Blueprint, 58条路由)
├── services/ # 服务层(飞书API客户端)
├── templates/ # Jinja2模板(33个HTML文件)
└── static/ # 静态资源(CDN为主)
应用工厂模式
app.py 不直接创建 Flask 实例,而是通过 create_app() 工厂函数:
def create_app():
app = Flask(__name__)
app.config.from_object(Config) # 注入配置
db.init_app(app) # 绑定数据库
# 注册7个Blueprint,每个注册两次
app.register_blueprint(sales_bp, url_prefix='/sales')
app.register_blueprint(sales_bp, url_prefix='/api/sales', name='sales_api')
# 上下文处理器:注入侧边栏导航到所有模板
@app.context_processor
def inject_modules():
return {'modules': [...]}
return app
为什么要工厂模式? 因为在飞书机器人进程中也要手动创建 app context 来操作数据库:
from app import create_app
app = create_app()
with app.app_context():
orders = SalesOrder.query.all()
双路由注册
每个 Blueprint 注册两次——直接的路径前缀和 /api 前缀。这是为了兼容隧道代理部署场景(如 ngrok),外部 URL 可能是 https://xxx.ngrok.io/api/sales/,而内部直接访问是 /sales/。
模型层组织方式
models/__init__.py 创建 db = SQLAlchemy() 实例,然后导入所有模型确保注册。这是 SQLAlchemy 的经典模式——模型需要在导入时被 SQLAlchemy 元数据"发现"。
6. 前端实现:一个 Bootstrap 打天下
技术策略
零构建工具、零静态文件。 所有 CSS/JS 通过 CDN 引入,页面样式内联在模板中。
- Bootstrap 5.3.3 CDN:布局 + 组件
- Bootstrap Icons 1.11.3:图标
- Chart.js 4.4.0:仪表盘图表
- 内联
<style>:自定义侧边栏、状态徽章、统计卡片
布局设计
┌──────────┬──────────────────────────┐
│ │ Topbar (面包屑 + 用户) │
│ Sidebar ├──────────────────────────┤
│ (240px) │ │
│ │ Page Content │
│ 仪表盘 │ (Bootstrap Grid + Card) │
│ 销售订货 │ │
│ 生产计划 │ │
│ 物料管控 │ │
│ 销售发货 │ │
│ 系统管理 │ │
│ 飞书集成 │ │
└──────────┴──────────────────────────┘
侧边栏使用 position: fixed 实现,深蓝色渐变背景。通过 context_processor 注入 modules 列表,模板中循环渲染导航项,根据 request.path 判断高亮。
仪表盘实现
用四个彩色统计卡片 + 两个 Chart.js 图表构成首屏:
- 统计卡片:订单总数 / 在产工单 / 待发货单 / 库存预警
- 柱状图:订单状态分布(订单用不同颜色区分状态)
- 环形图:工单状态分布
模板继承
base.html 定义了完整的骨架(sidebar + topbar + page-content),每个模块的页面只需:
{% extends "base.html" %}
{% block page_title %}销售订单列表{% endblock %}
{% block breadcrumb %}<li class="breadcrumb-item active">销售订货</li>{% endblock %}
{% block content %}
<!-- 页面内容 -->
{% endblock %}
7. 飞书集成:从回调到双机器人的演进
这部分是整个项目技术复杂度最高的模块。详细实现可参考飞书机器人接入博客,这里聚焦于演进过程。
阶段一:Webhook 回调
最初按飞书文档实现标准的事件回调——在飞书后台配置回调 URL,Flask 运行在公网可访问的地址上。写了一整套:
/feishu/event端点:URL 验证 + 消息事件处理/feishu/login+/feishu/callback:OAuth 登录/feishu/settings:管理页面
问题:内网环境无法被飞书服务器回调。虽然有 ngrok 等隧道方案,但不稳定。
阶段二:WebSocket 长连接
发现 lark-oapi SDK 支持 WebSocket 长连接模式——不需要公网 URL,客户端主动连接飞书 WS 服务器订阅事件。于是写了 feishu_ws.py:
def start_ws_client():
handler = EventDispatcherHandlerBuilder(
FEISHU_APP_ID, FEISHU_APP_SECRET
)
handler.register_event('im.message.receive_v1', handle_message)
ws_client = handler.build()
ws_client.start() # 阻塞方法,在新线程中运行
SDK 自动管理 token 刷新、重连、心跳,开发者只需关注 handle_message 函数。
阶段三:双机器人分离
单一机器人承载了销售和生产两套命令,消息路由日益复杂。最终拆分为两个独立飞书应用:
| 机器人 | App ID | 文件 | 职责 |
|---|---|---|---|
| 销售通用 | cli_aaba20... |
feishu_ws.py |
订单查询/创建/修改/删除、库存预警 |
| 生产专用 | cli_aab3fbe... |
feishu_ws_production.py |
工单 CRUD、排产/报工/完工/结案、日报/预警 |
两个机器人各跑独立进程,互不干扰。
消息路由
消息处理器(核心是文本匹配 + 正则提取):
def handle_message(client, event):
text = event.get('text', '')
text_clean = text.replace('@_all', '').strip()
if '新建工单' in text_clean:
reply = create_mo(text) # 解析多行键值对
elif '报工' in text_clean:
reply = report_progress(text) # 正则提取工单号+数量
elif '生产日报' in text_clean:
reply = daily_report()
# ... 更多路由
多行命令解析 是生产机器人的亮点——支持这样的输入:
新建工单
编码: P005
名称: 测试外壳
部门: 第一车间
数量: 500
开工: 2025-07-01
完工: 2025-07-15
订单: SO202406001
用正则按行提取字段名和值,支持中英文冒号、多种日期格式:
pattern = re.compile(r'(编码|名称|部门|数量|开工|完工|状态|...)[::]\s*(.+)')
8. 进程管理:一键启动的调度器
问题
系统由 3 个独立进程组成,手动启停非常繁琐:
- Flask Web 服务(TCP 5000)
- 飞书销售机器人(WebSocket 长连接)
- 飞书生产机器人(WebSocket 长连接)
解决方案:start.py
一个 70 行的进程管理器,使用 subprocess.Popen 启动所有子进程:
PROCESSES = [
('Flask', ['python', 'run_flask.py']),
('飞书-销售机器人', ['python', 'feishu_ws.py']),
('飞书-生产机器人', ['python', 'feishu_ws_production.py']),
]
for name, cmd in PROCESSES:
proc = subprocess.Popen(cmd, stdout=PIPE, stderr=STDOUT)
processes.append((name, proc))
关键特性:
- 命令行参数控制:
python start.py --no-flask只启飞书机器人 - 输出转发:每个子进程的 stdout 由独立线程转发到主控制台
- 进程监控:守护线程每 3 秒检查子进程是否存活
- 优雅关闭:注册 SIGINT/SIGTERM,先 terminate 后 kill
signal.signal(signal.SIGINT, graceful_shutdown)
signal.signal(signal.SIGTERM, graceful_shutdown)
9. 踩坑实录:5 个值得记录的教训
坑一:飞书长连接阻塞 Flask 主线程
ws_client.start() 是阻塞方法。如果在 app.py 的 __main__ 中直接调用,Flask 永远不会开始监听端口。
解决:扔进 daemon=True 的后台线程。但后来发现线程中的异常难以追踪,最终改用独立进程 start.py 管理。
坑二:card.action.trigger 不支持 WebSocket
想给生产机器人加卡片按钮交互(点击「开始生产」),但在飞书控制台搜不到事件。查文档才发现 card.action.trigger 只支持 Webhook 推送,不支持 WebSocket 长连接模式。
解决:放弃卡片交互,用纯文本命令覆盖所有场景。
坑三:@机器人消息包含 @_all 标记
飞书群聊中 @机器人,消息文本会被插入 @_all 或 @_user_1 等标记,干扰文本匹配。
解决:消息处理的第一步就是正则清洗:
text_clean = re.sub(r'@\S+', '', text).strip()
坑四:Windows 控制台 emoji 编码
飞书机器人回复大量使用了 emoji(🔧 ✅ ⚠️ 等),在 Windows CMD 下全部乱码。但这不是功能 bug——emoji 在飞书消息和网页中正常显示,只是控制台日志乱码。
解决:不修复。日志改用文字标识即可,emoji 只在飞书消息中使用。
坑五:数据库上下文隔离
两个飞书机器人进程各自用 from app import create_app 创建独立的 Flask 实例和 app context。如果不在 with app.app_context(): 中操作数据库,会报 RuntimeError: No application found。
最佳实践:所有数据库操作封装在函数中,在 feishu_ws.py 的入口创建一次 app context,内部函数直接用 db.session。
10. 总结与展望
成果
- 18 张数据表,58 条 Web 路由:覆盖销-产-存-发全链路
- 双飞书机器人:支持 20+ 条自然语言命令(订单 CRUD、工单 CRUD、排产、报工、日报、预警)
- 一键启动:
python start.py拉起全部服务 - 代码量:约 3500 行 Python + 3000 行 HTML
可改进的方向
- 用户认证:目前硬编码
created_by='sales01',缺乏真正的登录鉴权 - 飞书卡片交互:如果条件允许部署公网服务,启用
card.action.trigger的 Webhook 模式 - 数据可视化增强:更多维度的生产看板(OEE、产线负载、良品率)
- 消息通知:订单状态变更、工单延期时主动推送飞书消息
- 移动端适配:车间操作人员在手机上访问 Web 界面的体验优化
这个项目的 GitHub 仓库暂未公开,但如果你正在做类似的制造管理系统,希望这篇记录能给你一些参考。从 SQLite 文件出发,到飞书群聊里说一句"工单"就能看到排产情况——这个过程比想象中简单,也比想象中更折腾。
浙公网安备 33010602011771号