2023年秋天,一个做连锁餐饮的朋友找到我,说他们三个门店的收银数据每天都能导出,但店长基本不看,最多打开Excel看一眼今天盈亏,至于“这个月哪个菜品在悄悄下滑”“周末下午茶时段到底值不值得加人手”这类问题,完全靠感觉。于是我用Python Flask做了一个基于销售趋势的餐饮管理系统,题目里那个随机后缀“9qurrf09”其实就是当时项目的内部代号。系统核心就三件事:把收银数据接进来、统计销售趋势、把结果投到办公室的电视机上做大屏可视化展示。这篇文章把整个设计过程和踩坑记录完整写下来,给正在用Flask做数据分析类应用,或者想给自己店里做一套轻量可视化报表的人参考。
1. 别急着写代码:先想清楚餐饮管理系统到底要管什么
1.1 一个典型门店的销售数据里藏着哪些问题
大部分餐饮老板不是没有数据,而是数据躺在Excel里、收银机后台里,根本没人去拆。我梳理朋友的门店数据后发现,最常被问到的问题其实高度集中:
- 今天、本周、本月的营业额跟前几天、前几周、去年同期比是涨是跌?
- 哪个菜品贡献了主要收入,哪个菜品销量在持续下滑?
- 中午和晚上两个高峰哪个更重要,下午茶时段值不值得做活动?
- 堂食、外卖、自提三种订单类型各占多少比例?
这些问题听起来简单,但翻Excel去手工算非常痛苦,尤其要跨店对比的时候。系统要解决的,就是把“从数据里找答案”变成打开电视就能看到结果。所以餐饮管理系统的本质不是记账,而是把经营数据的分析链路自动化。
1.2 功能边界:管理端、数据接入和大屏展示
做系统最怕一开始就贪大。当时我给自己定的边界非常明确,第一版只做三类功能:
第一类是基础管理,包括菜品、门店、订单的维护与查看。登录页、菜品管理页、订单流水页都要有,但不需要复杂的权限体系,管理员和店长两种角色就够了。
第二类是销售趋势分析,这是系统的大脑。所有统计都围绕时间维度、门店维度、菜品维度展开,输出营业额趋势、订单量趋势、客单价变化、菜品销量排行、时段分布等指标。
第三类是可视化大屏,这是系统的脸面。在办公室墙上挂一块屏幕,自动轮询刷新数据,展示核心KPI、趋势折线图、品类占比饼图、时段热力图和菜品排行榜,让经营状况一眼可见。
技术上选Python Flask是非常自然的事。Flask足够轻量,一个app.py加几个蓝图就能跑起来,配合SQLAlchemy操作SQLite,几乎不需要额外基础设施。如果你数据量不大、又希望本地一键部署,Flask这套组合比Django轻,比Node.js对数据分析场景更顺手。前端不用Vue,直接HTML + ECharts + 少量原生JavaScript,因为大屏页面不需要复杂的交互,纯展示场景反而越简单越稳定。
2. 数据模型与指标口径:销售趋势分析的地基
2.1 四张核心表怎么设计
餐饮销售分析的数据模型不需要很多表,但每张表的字段一定要贴合分析需求。我最终采用了门店、菜品、订单、订单明细四张核心表,外加一张品类表。
门店表存门店名称、城市、营业面积、开业日期。菜品表存菜品名称、所属品类、单价、成本价、状态。订单表是销售事实表,存订单号、门店ID、订单类型(堂食/外卖/自提)、支付金额、下单时间。订单明细表存每个订单里的菜品行,包括菜品ID、数量、单价小计。
这里有一个容易忽略的重点:支付金额放在订单表,菜品小计金额放在明细表,两边都要存。因为大屏上的“营业额”必须按订单表汇总,而“菜品销售额”按明细表汇总,如果只存一边,两类指标总会对不上。
订单表设计时最好把下单时间直接拆出冗余字段,比如order_date、order_hour,而不是全部靠SQL函数动态提取。SQLite数据量小的时候无所谓,但一旦跑几个月的数据,每天聚合都做date(created_at)转换,速度明显变慢。我当时在写入时同步生成date和hour字段,聚合查询只需要group by冗余字段,快了很多。
2.2 关键经营指标的计算口径
指标口径不提前定清楚,大屏上线就是灾难。同一个“营业额”,财务按实收算,店长按菜单价算,最后发现数字差了一两万。我在系统里把所有指标的口径写死在统计模块的注释里,并同步登记成文档。
| 指标 | 计算口径 | 展示位置 |
|---|---|---|
| 今日营业额 | 订单支付完成且未退单的支付金额合计 | 大屏顶部KPI |
| 订单量 | 有效订单数合计 | 大屏顶部KPI |
| 客单价 | 支付金额合计 / 订单量 | 大屏顶部KPI |
| 环比 | (本期值 - 上期值) / 上期值 | 大屏折线图旁 |
| 品类占比 | 品类销售额合计 / 总销售额 | 大屏饼图 |
| 菜品销量TOP10 | 订单明细中菜品数量合计 | 大屏榜单 |
| 时段分布 | 按order_hour汇总的订单量与销售额 | 大屏热力图 |
环比必须注意“上期”的定义。如果要对比今天和昨天,那么每一小时都要对上;如果要对比本周和上周,则要处理周一和周日对齐的问题。我当时的做法是:日环比固定对比最近一个非当天日期,周环比固定对比最近一个完整周期,避免“部分周”数据造成的统计失真。
菜品成本字段虽然不直接上大屏,但毛利分析对餐饮管理非常关键。我建议菜品表里把成本价也存上,这样后续想做“高销量低收入菜品识别”,直接计算(售价-成本)×销量就能找出利润黑洞,不需要改表结构。第一版用不上没关系,表设计时预留这个字段成本最低。
3. Flask后端:用ORM聚合代替手工统计
3.1 项目工程结构
我习惯把Flask项目按模块拆开,而不是全部塞进app.py。这个系统的工程结构如下:
restaurant_sa/ ├── app.py ├── config.py ├── extensions.py ├── models/ │ ├── __init__.py │ ├── store.py │ ├── dish.py │ └── order.py ├── blueprints/ │ ├── __init__.py │ ├── auth.py │ ├── admin.py │ └── dashboard.py ├── services/ │ └── stats.py ├── templates/ │ ├── login.html │ ├── dashboard.html │ └── bigscreen.html ├── static/ │ ├── css/ │ ├── js/ │ └── vendor/ └── data/ ├── init_data.sql └── orders_sample.csvservice层单独拆出来是我特别想强调的一点。很多Flask教程把查询逻辑直接写在路由函数里,第二版加功能时路由文件迅速膨胀。stats.py专门放所有聚合统计函数,dashboard.py只负责接收参数、调用service、返回JSON,这样大屏接口和后台页面对接的时候,逻辑完全复用。
3.2 销售趋势接口的聚合查询写法
销售趋势接口是系统最核心的API。前端大屏需要返回过去14天每天的营业额和订单量,最直接的做法是用SQLAlchemy的func按日期分组。
from flask import Blueprint, jsonify, request from sqlalchemy import func from models import Order from extensions import db dashboard = Blueprint("dashboard", __name__) @dashboard.route("/api/trend/daily") def trend_daily(): days = int(request.args.get("days", 14)) start = date.today() - timedelta(days=days - 1) rows = ( db.session.query( Order.order_date, func.sum(Order.pay_amount).label("amount"), func.count(Order.id).label("orders") ) .filter(Order.order_date >= start) .group_by(Order.order_date) .order_by(Order.order_date) .all() ) result = [ { "date": str(r.order_date), "amount": round(r.amount, 2), "orders": r.orders } for r in rows ] return jsonify({"code": 0, "data": result})这里我故意在系统里冗余了order_date字段,而不是直接使用func.date(Order.created_at),就是因为聚合频率太高,冗余字段配合索引可以把14天聚合查询的时间从几百毫秒降到几十毫秒。SQLite虽然数据量不大,但大屏页面每30秒轮询一次,慢查询会影响整个屏幕的刷新节奏。
时段分布的聚合逻辑略有不同,需要按order_date和order_hour两个字段分组。菜品排行也类似,但要去关联order_item和dish两张表。把这些查询统一放到stats.py的好处是:所有统计口径只在一个文件里维护,后续换成MySQL只改数据库连接字符串,SQLAlchemy查询语句不需要动。
3.3 CORS、时区与序列化细节
Flask后端给大屏页面取数时,如果大屏页面和Flask服务是同一个域名下,就不存在跨域问题。我当时开发时大屏页面是通过Flask的render_template直接渲染的,接口路径统一走/api,所以完全不需要CORS配置。如果你坚持前端和Flask分离部署,才需要考虑flask-cors。
时区问题必须提醒一句。如果你用datetime.utcnow()存时间,SQLite里看到的是UTC时间,但中国门店的营业高峰是中午11点到13点,晚上17点到20点,UTC和本地时间相差8小时,时段分布图会彻底错乱。我的处理方式是:写入订单时间时直接用本地时间,不存UTC;涉及日期分组时,只读取冗余的order_date和order_hour字段,不依赖UTC时间转换。系统如果以后要接入支付平台回调,必须改回UTC存储,但那是分布式系统的课题,单机餐饮系统不需要自找麻烦。
JSON序列化时遇到Decimal类型,Flask默认的jsonify会报错或者序列化成字符串。我建议在config.py中统一注册一个JSONEncoder,把Decimal转成float,date转成str。避免在每个接口里手工转换,这个编码器文件可以长期复用。
4. 可视化大屏:把指标变成老板看得懂的图
4.1 大屏布局:先画个栅格草图
可视化大屏最容易犯的错误是一上来就写ECharts代码,结果布局乱、图表堆在一起。我的习惯是先画栅格草图,固定1920×1080设计稿,再用CSS做缩放适配。
大屏布局我采用的是经典的三列结构。
顶部区域是标题栏,展示“XX餐饮销售趋势大屏”、当前日期时间、数据最后刷新时间。中间核心区域是主体,左列放今日营业额、今日订单量、客单价三个KPI卡,以及品类占比饼图;中间列放销售趋势折线图,这是整个大屏的视觉焦点;右列放菜品销量TOP10榜单,以及门店对比柱状图。底部区域放时段分布热力图和订单类型占比环形图。这样的布局保证管理层一抬头先看到“今天赚了多少钱”,再看到趋势变化,最后才是结构性分析。
KPI卡不建议直接放一个光秃秃的数字。餐饮老板对数字的敏感度有限,必须给出变化趋势。每个KPI卡的下方配一个小箭头和环比百分比,红色向上、绿色向下,语义一看就懂。环比数据的计算在接口层完成,前端只负责渲染,不承担任何统计逻辑。
4.2 ECharts核心配置:从接口到图表的完整链路
ECharts是可视化大屏的主力,不需要引入Vue或React。以中间那条销售趋势折线图为例,核心配置集中在fetch数据后的option构造:
async function loadTrend() { const resp = await fetch("/api/trend/daily?days=14"); const json = await resp.json(); const data = json.data; trendChart.setOption({ title: { text: "最近14天销售趋势", left: 24 }, tooltip: { trigger: "axis" }, legend: { data: ["营业额", "订单量"], top: 8, right: 24 }, grid: { left: 70, right: 70, top: 60, bottom: 40 }, xAxis: { type: "category", data: data.map(d => d.date.slice(5)), }, yAxis: [ { type: "value", name: "营业额(元)" }, { type: "value", name: "订单量(单)" } ], series: [ { name: "营业额", type: "line", smooth: true, data: data.map(d => d.amount), itemStyle: { color: "#36d6c4" }, }, { name: "订单量", type: "bar", yAxisIndex: 1, data: data.map(d => d.orders), itemStyle: { color: "rgba(80, 141, 255, 0.6)" }, } ], }); }营业额用折线、订单量用柱状,这样同一个图同时呈现两种指标,视觉不冲突。填ECharts配置时有三个细节容易出问题。
第一,yAxis如果同时显示金额和订单数量,金额轴的单位差很大,必须用双y轴,否则订单量在图上会变成一条贴地的线。
第二,tooltip默认显示的数值可能是一大串小数,给餐饮老板看必须做格式化,在tooltip的valueFormatter里把金额转成带千分位逗号的字符串。
第三,折线图的圆点默认在每个数据点显示,14天还好,如果改成30天就会显得特别乱,要设置showSymbol: false,只在hover时显示强调点。
时段热力图是我觉得效果最好的图。ECharts的heatmap配合visualMap组件,把“星期”作为X轴、“营业小时”作为Y轴,颜色越深代表销售额越高,一眼就能看出周五晚上的峰值。这个图对门店排班的参考价值极大,很多店长看完之后才知道自己店里的低谷时段到底有多低。
4.3 大屏适配与自动刷新
大屏的显示器通常不是标准1920×1080,有可能是一台老款电视、一块竖屏广告机,所以我直接在body里做等比缩放。具体做法是:设计稿固定1920×1080,页面内容套在一个#screen容器里,JavaScript根据window.innerWidth与1920的比例设置容器的transform: scale。窗口resize时重新计算,整体等比缩放,不出现滚动条。
function scaleScreen() { const el = document.getElementById("screen"); const scaleX = window.innerWidth / 1920; const scaleY = window.innerHeight / 1080; const scale = Math.min(scaleX, scaleY); el.style.transform = `scale(${scale})`; } window.addEventListener("resize", scaleScreen); scaleScreen();自动刷新也要考虑后端压力。我当时给大屏接口做了一个统一的刷新调度器,全屏只有四个数据请求:KPI汇总、14天趋势、菜品排行榜、品类占比,每个接口独立设置轮询间隔,KPI每10秒刷新一次,趋势图每30秒刷新一次,排行榜和品类占比每60秒刷新一次。刷新频率不是越快越好,餐饮数据本身有延迟,刷太快只会增加无谓的数据库查询。如果店里的收银机数据不是实时写入,大屏甚至只需要每分钟刷新一次。
5. 本地部署、联调与踩坑记录
5.1 一键启动与配置模板
这套系统在设计时就定位于“可本地部署”,所以启动流程必须足够傻瓜。我把配置信息全部放在config.py的Config类中,关键字段包括SECRET_KEY、SQLALCHEMY_DATABASE_URI、轮询间隔。数据库连接串默认指向data/restaurant.db,如果想切换到MySQL,只需要修改连接串并安装pymysql驱动。
首次部署时,我写了一个init_db.py脚本:自动创建表结构、插入门店和菜品初始数据、生成一个月模拟销售订单。脚本里的模拟订单生成器很有用,它按照餐饮行业的实际规律生成数据——周一和周三订单量偏低,周五周六明显上升,午餐高峰11点到13点、晚餐高峰17点到20点,外卖订单集中在午间。这样生成的数据集,大屏展示出来就很贴近真实经营曲线,用来做功能演示非常合适。
启动方式就是一行命令:
python init_db.py python app.py浏览器访问本机5000端口,先登录,然后进入大屏页面。整个流程不需要安装额外的服务软件,也不需要配置环境变量。Windows和macOS都能直接跑,只要Python版本大于等于3.9。
5.2 我实际踩过的三个坑
第一个坑是SQLite的“database is locked”。开发时我用Flask自带的开发服务器,默认单进程单线程,当大屏多个接口同时访问数据库时,SQLite的写入锁会导致查询报错。解决方案有两个:一是通过SQLAlchemy连接串配置连接池和超时,二是把开发服务器设置为多线程运行。
app.run(debug=True, threaded=True)如果部署到生产环境,不要用Flask开发服务器,用gunicorn或者uwsgi,多worker模式下SQLite会出现更频繁的锁竞争,那时就得认真考虑换MySQL。
第二个坑是菜品多音字和别名匹配。做菜品销量排行时,我发现同一个菜品在订单里出现了“酸菜鱼”“老坛酸菜鱼”“酸菜鱼(大份)”三种写法,导致排行被拆成三条数据。这在真实数据里非常常见。我的处理是给菜品表加了一个别名匹配规则,在订单明细导入时,如果商品名称能模糊匹配到菜品表,就归并到对应菜品ID,匹配不到的才新建临时菜品。模糊匹配用简单的包含判断就够了,不需要上分词算法。
第三个坑是ECharts大屏在部分老版本浏览器上显示空白。因为大屏终端经常是旧的电视盒子,系统自带浏览器版本低,ES6语法无法解析。我当时把前端代码用Babel做了转译,并且所有ECharts资源文件改为本地引入,不用CDN。这样内网环境下大屏也能快速打开,不会因为外网挂掉而白屏。
大屏页面的时间显示也需要注意。我最初用JavaScript的toLocaleTimeString()在部分浏览器上显示12小时制的“下午3:30”,老板看着不习惯。后来统一改成24小时制,并自己补零格式化,效果稳定,也不再依赖浏览器locale。
5.3 后续扩展方向
这套系统跑通之后,最自然的扩展方向有三个。
第一个是销售预测。有历史订单数据之后,可以按时间序列做未来三天营业额预测。Flask本身不擅长机器学习,可以先从简单的移动平均和同比系数法开始,比如去年今天和上周今天加权平均,效果就足够门店备货参考了。等积累的数据量足够大,再接入Prophet或再训练一个LightGBM模型都不迟。
第二个是库存联动。菜品表里已经预留了成本价和SKU字段,订单明细汇总出每天每个菜品的销量之后,可以自动算出需要的原物料采购量。这样大屏就不只是看经营情况,还能直接影响第二天的备货清单。
第三个是门店间对比。如果有多家门店,可以在大屏上增加一个“门店同比雷达图”,把翻台率、客单价、毛利率、订单密度几个维度放在一起做对比,指出哪家门店哪个指标拖了后腿。这比分开看各店数据要直观得多。
我自己的体会是:餐饮管理系统的价值不在于系统本身有多严谨,也不在于算法有多高级,而在于它能不能把经营数据转化成看得懂、来得及反应的信号。那个挂在办公室墙上的大屏,最开始只是朋友觉得“挺酷”才要做的,后来他跟我说,最常用的是每天早上的第一眼——前天和昨天的对比一眼就能看出来,该追哪个菜品的库存、该跟哪个门店开会,心里有数了。如果让我重来一次,第一版我连登录功能都可以后置,先把大屏接上真实数据给老板看,看到实际效果,后面所有优化才有动力。你如果也要做类似系统,建议也从最小的闭环开始跑:数据导入、趋势接口、一块大屏,足矣。