Flask+ECharts:餐饮销售趋势分析与可视化大屏实践 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(daysdays - 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转成floatdate转成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?days14); 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(debugTrue, threadedTrue)如果部署到生产环境不要用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字段订单明细汇总出每天每个菜品的销量之后可以自动算出需要的原物料采购量。这样大屏就不只是看经营情况还能直接影响第二天的备货清单。第三个是门店间对比。如果有多家门店可以在大屏上增加一个“门店同比雷达图”把翻台率、客单价、毛利率、订单密度几个维度放在一起做对比指出哪家门店哪个指标拖了后腿。这比分开看各店数据要直观得多。我自己的体会是餐饮管理系统的价值不在于系统本身有多严谨也不在于算法有多高级而在于它能不能把经营数据转化成看得懂、来得及反应的信号。那个挂在办公室墙上的大屏最开始只是朋友觉得“挺酷”才要做的后来他跟我说最常用的是每天早上的第一眼——前天和昨天的对比一眼就能看出来该追哪个菜品的库存、该跟哪个门店开会心里有数了。如果让我重来一次第一版我连登录功能都可以后置先把大屏接上真实数据给老板看看到实际效果后面所有优化才有动力。你如果也要做类似系统建议也从最小的闭环开始跑数据导入、趋势接口、一块大屏足矣。