做数据分析这几年,接过的销售统计项目不算少,但“在线中药店”这个场景,算是同事问我最多的一种类型。它表面上只是把订单数据汇总成报表,真正上手之后你才会发现,中药销售数据里藏着一堆其它行业碰不到的细节:同一味药有饮片和颗粒两种规格,价格能差出好几倍;秋冬进补季的销售额能顶得上全年的大头;一张三百块的处方单里,甘草往往只占两块钱,但它就是几乎每一单都在。今天这篇,我就把整个“基于Python的在线中药店销售数据统计与分析系统”的思路和数据流程完整拆开讲一遍,包括表结构设计、核心统计指标、可视化实现,以及我在实际开发中踩过的坑。无论你是拿它当毕业设计参考,还是想给自家门店搭一套数据分析看板,这篇文章都能给你一条可以直接照抄的路径。
这套系统本质上是给一家在线中药店做销售数据的“透视镜”。原始数据来自商城订单库,里面有用户信息、药品信息、订单明细、支付金额、发货状态等十几个字段,看起来挺全,但真要拿来分析,格式乱、字段杂、单位不统一,根本没法直接用。系统要做的就是把这些脏数据洗成标准格式,然后按日、周、月、季度自动汇总,输出销售趋势、药品排行、顾客画像、季节规律四类核心报表,最后通过Web页面展示成一个可视化的数据分析平台。源码和配套文档都整理好了,下面整个分析流程从头到尾过一遍。
1. 项目整体设计与模块拆解
1.1 销售数据背后的四个关键问题
任何一套数据系统,先想清楚要回答什么问题,再动手写代码。这套系统当时立项时,业务方提了四个核心问题,我原样贴在需求文档里:
- 哪些中药卖得好?——需要统计销量和销售额TOP10、TOP20榜单,计算每个单品对总营收的贡献率。
- 销售有没有明显的周期规律?——中药行业有明显的淡旺季,需要按周、按月、按季度看趋势曲线,找出波峰波谷。
- 顾客是谁?复购情况怎么样?——需要分析新老客户占比、复购周期、客单价分布,判断哪些用户是高价值客户。
- 库存和销售之间怎么联动?——当月哪些药卖得快、哪些药滞销,这些信息直接反馈给采购部门做库存调整。
这四个问题决定了系统不是一个简单的“求和工具”,而是一个带时间维度的多维分析平台。设计时我坚持一个原则:报表可以简单,但分析维度必须能自由组合。比如“Q3补气类药材的复购客单价”,这种组合查询在临时需求里经常出现,如果前期表结构设计得不好,后面每查一次就要写一次复杂SQL,非常痛苦。
1.2 系统整体模块划分
系统按功能拆成五个模块,层与层之间相互独立,通过标准数据接口通信:
| 模块 | 职责说明 | 核心文件 |
|---|---|---|
| 数据导入层 | 对接订单库原始数据,支持Excel/CSV批量导入和MySQL直连 | data_loader.py |
| 数据清洗层 | 缺失值处理、格式统一、单位换算、异常值过滤 | data_cleaner.py |
| 统计计算层 | 销售汇总、趋势分析、TOP榜单、顾客画像、复购计算 | statistics.py |
| 可视化展示层 | 生成折线图、柱状图、饼图、热力图,输出HTML报表 | charts.py |
| Web服务层 | 基于Flask提供页面访问,用户可按日期和品类筛选数据 | app.py |
这里最容易被初学者忽略的是“数据导入层”和“数据清洗层”。很多人在学校做项目,拿到Excel直接read_csv就开始分析,看似快了,但一旦数据量到几十万行、脏数据比例超过5%,后面所有统计结果都会偏离真实情况。这个项目里,清洗层差不多占了整个开发时间的三分之一,这也是真实项目和课堂作业最大的区别。
1.3 为什么把可视化单独拆一层
如果你用过matplotlib,就知道同样一张图,参数换一换,出来的效果天差地别。把可视化封装成独立模块的好处有两个:一是统计计算结果可以复用,比如同一个top10_data既能出柱状图,也能出饼图;二是后期换图表库方便,比如从matplotlib换成pyecharts做交互图表,只需要改charts.py一个文件,业务代码完全不用动。
2. 数据层设计:从订单表到分析宽表
2.1 原始订单数据结构剖析
这个项目的数据来自某在线中药店的商城系统,导出的一张订单主表,包含的字段大致有这些:
order_id(订单号)、user_id(用户ID)、user_name(用户姓名) user_phone(手机号)、drug_name(药品名称)、drug_category(药品分类) specification(规格)、quantity(购买数量)、price(成交单价) order_amount(订单金额)、order_status(订单状态)、pay_time(支付时间)实际数据有四万多条,覆盖了2023年1月到2023年12月一整年的订单。如果直接拿这张表做分析,会遇到几个棘手问题:一个订单可能包含多种药品,但订单行只有一列“药品名称”,多个药名挤在同一格子里;价格字段有的是字符串“¥128.00”,有的是数字;订单状态五花八门,“已支付”“已完成”“已发货”“待付款”混在一起。所以数据清洗的第一步,就是把一张“流水大宽表”拆成规范的关系表。
2.2 四张核心表的设计
我按第三范式重新设计了数据模型,核心是下面四张表:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| dim_drug | 药品维度表 | drug_id、drug_name、category、specification、unit、price |
| dim_user | 顾客维度表 | user_id、user_name、gender、age、register_time |
| fact_order | 订单事实表 | order_id、user_id、order_amount、pay_time、order_status |
| fact_order_item | 订单明细表 | order_id、drug_id、quantity、subtotal |
事实表和维度表分开,好处非常明显。统计“阿胶的季度销售额”时,只需要把fact_order_item按drug_id关联到dim_drug,就可以按任意维度切片;同样一个用户买了几次药,从fact_order查user_id的计数就能得到复购次数,不用去全文匹配用户名。
2.3 中药数据的特殊处理
中药数据的清洗有几个非常容易被坑的地方,我单独拿出来说:
首先是规格和单位的混乱。同样一味“黄芪”,有“500g/罐”的饮片,有“10g/袋”的小包装,还有“3g*20袋/盒”的颗粒剂。如果不对规格字段做标准化,统计销量时就会出现“100袋”和“2罐”直接相加的错误。我的处理方式是:把每个商品ID对应的规格信息维护在dim_drug表里,统计时先统一换算成标准单位“克”,再参与汇总。
其次是药名别名的处理。在线中药店会有人参、红参、生晒参这种相近但不同价的商品,如果不区分,关键词统计时会混在一起。系统里维护了一张别名映射表,把“红参片”“红参须”“红参粉”这类同源但形态不同的商品归并成一级品类,这样品类层面的统计才不会被冲散。
再次是异常订单的过滤。退款订单、测试订单、金额为0的订单,这些在报表里都应该剔除。我的过滤规则很简单但实用:order_status只保留“已完成”和“已发货”,“退款”的订单自动去除;订单金额小于1元的一律视为测试数据;支付时间为空的数据直接丢弃。
3. 核心统计指标与分析逻辑
3.1 销售总览指标
销售总览是数据大屏最上面的那排数字,包括总销售额(GMV)、总订单数、客单价、付费用户数。这些指标用pandas实现非常简单,核心代码就几行:
total_gmv = df[df['is_valid'] == 1]['order_amount'].sum() total_orders = df[df['is_valid'] == 1]['order_id'].nunique() total_users = df[df['is_valid'] == 1]['user_id'].nunique() avg_order_value = total_gmv / total_orders这里要注意一个细节:统计总订单数时用的是nunique()而不是count(),因为一个订单在订单明细表里有多行记录,直接count会把同一个订单重复算三次。这种问题不做数据分析的人根本想不到,也是评审老师最喜欢问的“数据口径问题”。
再往下一层,是分渠道、分品类的销售额构成。中药店在线销售通常会区分“自营小程序”和“第三方平台”两个渠道,每个渠道的客单价、退货率差异很大,分开统计才更有业务参考价值。系统里通过订单号前缀区分渠道,在fact_order表里增加channel字段,统计时按这个字段分组:
channel_stats = df.groupby('channel').agg( gmv=('order_amount', 'sum'), order_cnt=('order_id', 'nunique'), user_cnt=('user_id', 'nunique') ) channel_stats['avg_order'] = channel_stats['gmv'] / channel_stats['order_cnt']3.2 药品销售排行与帕累托分析
药品排行榜是业务方看的最多的一个报表。“哪些药卖得好”直接决定了下一个月的进货策略。实现上就是分组聚合后排序取前N条:
drug_sales = df.groupby('drug_name').agg( sales_amount=('subtotal', 'sum'), sales_quantity=('quantity', 'sum'), order_cnt=('order_id', 'nunique') ).reset_index() drug_sales = drug_sales.sort_values('sales_amount', ascending=False) drug_sales['cum_ratio'] = drug_sales['sales_amount'].cumsum() / drug_sales['sales_amount'].sum()最后一行cum_ratio是累计占比,用于帕累托分析,也就是常说的“二八定律”。从实际数据看,这家在线中药店大概17%的SKU贡献了80%的销售额,属于非常典型的“头部集中型”品类结构。对于头部品种,库存不能断;对于尾部占80%却只贡献20%销售额的长尾品种,则要控制采购量,避免积压。
3.3 季节趋势与周期性分析
中药销售有个非常明显的季节规律:秋冬进补季(10月到次年1月)销售额是春夏淡季的两倍以上。为了验证这个规律,系统按月份聚类汇总:
monthly = df.groupby(df['pay_time'].dt.to_period('M')).agg( gmv=('order_amount', 'sum'), orders=('order_id', 'nunique') ) monthly['avg_order'] = monthly['gmv'] / monthly['orders']从实际跑出来的曲线看,全年有两个波峰:一个在春节前(年货送礼需求),一个在秋冬降温后(进补需求)。这两个波峰对应着两类完全不同的商品——春节前卖得最好的是阿胶糕、即食燕窝这类礼盒装,秋冬卖得最好的是黄芪、当归、党参这类炖汤料包。做运营的人看到这条曲线,就能提前两个月规划营销活动。
3.4 顾客画像与复购行为分析
顾客维度主要做三个事:新老客户占比、复购率、购买力分层。
新老客户占比的实现方式是按客户首次购买时间做标记:
first_purchase = df.groupby('user_id')['pay_time'].min().rename('first_time') df = df.join(first_purchase, on='user_id') df['user_type'] = np.where( df['pay_time'] == df['first_time'], 'new', 'old' )复购率用“次月复购率”来定义:某月的新客里,有多少人在下个月再次下单。这个指标比累计复购率更能反映运营活动的拉新质量。跑完数据发现,这家店的新客次月复购率只有23%,看起来不高,但老客的年均购买次数有6.8次,说明顾客一旦认可了这家店,忠诚度相当可观。问题就出在新客转化上——针对这一点,后来给运营提了建议:新客首单后7天内发放一张“满99减15”的复购券,实测次月复购率提升了大概5个百分点。
购买力分层则用客单价四分位数:
user_gmv = df.groupby('user_id')['order_amount'].sum() q75, q50, q25 = user_gmv.quantile([0.75, 0.5, 0.25])按>q75为高价值用户、q50~q75为中价值用户、<q25为低价值用户分层,形成顾客价值金字塔。分析结果里有个有意思的现象:高价值用户只占总用户数的8.6%,却贡献了44%的销售额,而且这批用户偏好在工作日白天下单,购买品类集中在参茸滋补类。针对这批用户做定向回访和维护,ROI明显比拉新活动高。
4. 可视化与Web报表实现
4.1 图表选型:静态图还是交互图
数据分析的结果最终要给别人看,可视化环节不能糊弄。这个系统里我用了两套方案:
- matplotlib:用于导出PDF周报、月报,风格偏学术,打印出来很清晰。
- pyecharts:用于Web端交互图表,鼠标悬停能看到具体数值,支持缩放、保存图片。
如果你只是自己看数据,matplotlib就够了;如果是给业务部门搭看板,强烈建议直接用pyecharts,交互体验完全不一样。比如趋势图加一个datazoom滚动条,就能同时看清全年趋势和某个月的细节。
4.2 核心图表:月度销售趋势与TOP10排名
月度销售趋势是整个仪表盘的主图,用pyecharts的Line实现:
from pyecharts.charts import Line from pyecharts import options as opts line = ( Line() .add_xaxis(month_list) .add_yaxis( "销售额", gmv_list, is_smooth=True, label_opts=opts.LabelOpts(is_show=False), ) .set_global_opts( title_opts=opts.TitleOpts(title="2023年月度销售额趋势"), tooltip_opts=opts.TooltipOpts(trigger="axis"), datazoom_opts=[opts.DataZoomOpts()], ) )药品销售额TOP10排行用横向柱状图排出名次,一眼扫过去就知道头部品种是哪几个。同时我在柱状图上方叠加了一个“销售额累计占比”的折线,形成帕累托复合图,这样管理层看一张图就能理解“头部品种该重点维护”这个结论。
4.3 Flask整合:把图表变成在线看板
图表模块做完后,用Flask搭一个轻量级Web服务,把图表渲染成HTML页面嵌入看板。核心就是一个路由渲染:
from flask import Flask, render_template import charts app = Flask(__name__) @app.route("/") def dashboard(): trend_plot = charts.render_monthly_trend() top_plot = charts.render_top10_drugs() return render_template( "dashboard.html", trend_plot=trend_plot, top_plot=top_plot, ) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)render_template把pyecharts生成的HTML片段嵌入到dashboard.html模板里。业务方打开浏览器输入地址,就能看到完整的销售分析看板,还可以通过左侧筛选器切换日期范围、药品品类,整个过程不需要安装任何Python环境。这一步做完,系统才算真正交付,不然光给一堆.ipynb文件,业务方根本不会用。
5. 完整实操流程:从零到一跑通项目
5.1 环境准备与依赖安装
项目基于Python 3.9开发,建议用虚拟环境隔离依赖,避免和系统Python冲突:
python -m venv venv source venv/bin/activate pip install pandas numpy matplotlib pyecharts flask openpyxl这里特别提一下openpyxl——很多人读Excel时报错ModuleNotFoundError: No module named 'openpyxl',就是因为没装这个库。pandas读取.xlsx文件依赖它,.xls则依赖xlrd。项目上建议直接用.xlsx格式,省去一堆麻烦。
5.2 数据导入与清洗完整代码
以下是数据清洗的完整核心逻辑,我把注释写得比较详细,可以直接复用:
import pandas as pd import numpy as np def load_and_clean(filepath): df = pd.read_excel(filepath) # 1. 删除完全重复的行 df = df.drop_duplicates() # 2. 删除全空列、全空行 df = df.dropna(axis=1, how='all').dropna(axis=0, how='all') # 3. 规范订单状态,保留有效订单 valid_status = ['已完成', '已发货', '已签收'] df = df[df['order_status'].isin(valid_status)] # 4. 清洗价格字段:去掉货币符号,转成float df['price'] = df['price'].astype(str).str.replace('¥', '').str.replace(',', '') df['price'] = pd.to_numeric(df['price'], errors='coerce') # 5. 删除订单金额异常的数据(测试单、退款单) df = df[df['order_amount'] > 1] # 6. 日期字段标准化 df['pay_time'] = pd.to_datetime(df['pay_time'], errors='coerce') df = df.dropna(subset=['pay_time']) return df第4步的errors='coerce'很关键,它会把无法转换成数字的值变成NaN,之后通过dropna统一清理,而不是在转换时报错中断。面对真实脏数据,永远先想“怎么优雅地处理坏数据”,而不是“假装坏数据不存在”。
5.3 一键生成全套统计报表
项目里写了一个run_analysis.py主脚本,把导入、清洗、统计、绘图、导出串成一条流水线:
python run_analysis.py --input data/raw_orders.xlsx --output report/脚本执行完,report/目录下会生成:
summary_stats.xlsx:汇总统计表,包含各类核心指标。monthly_trend.html:月度趋势交互图。drug_top10.html:药品销售排行榜。customer_segmentation.xlsx:顾客分层明细。analysis_report.pdf:自动生成的图文分析报告。
整个流程跑完大概十几秒,四万条数据对pandas来说是小意思,不需要上Spark之类的分布式框架。如果数据量到了几百万行,再考虑用Dask或ClickHouse提升性能。
6. 常见问题与排查技巧
6.1 图表中文乱码怎么办
用matplotlib画图,默认字体不支持中文,标题显示成方框。解决办法是全局指定中文字体:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei'] plt.rcParams['axes.unicode_minus'] = False第二行axes.unicode_minus是处理负号显示异常的,坐标轴有负值时一定要加。另外Linux服务器上可能没有SimHei,需要先安装中文字体或用font_manager指定字体路径,这一点部署到服务器时要提前检查。
6.2 pandas读取CSV编码报错
中药店的订单导出CSV如果直接pd.read_csv('data.csv'),经常报UnicodeDecodeError。原因是Windows版Excel默认用GBK编码保存CSV,而pandas默认用UTF-8读取。解决办法是显式指定编码:
df = pd.read_csv('data.csv', encoding='gbk')如果不确定文件编码,可以用chardet自动检测:
import chardet with open('data.csv', 'rb') as f: result = chardet.detect(f.read(10000)) df = pd.read_csv('data.csv', encoding=result['encoding'])注意detect时不要读全文件,读前几千字节判断编码就够了,读全量大反而慢。
6.3 时间维度数据对不齐
按月统计时经常会出现“少了某个月份”或“月份顺序错乱”的问题。原因是源数据里某些月份一笔订单都没有,groupby之后这些空月份压根不会出现。解决办法是用reindex补齐完整的时间索引:
monthly = monthly.reindex(pd.period_range('2023-01', '2023-12', freq='M'), fill_value=0)这样画出来的趋势图才是一条连续的时间轴,而不是中间断了一截的残图。类似的逻辑也适用于按周统计。
6.4 数据量大时运行变慢的优化思路
如果后续数据量增长到几十万行,pandas基础操作仍然能扛,但有几个优化习惯建议提前养成:
- 读取时指定需要用的列,别傻乎乎全读进内存:
pd.read_excel(filepath, usecols=['A', 'C', 'E'])。 - 对于状态、性别这种取值有限的列,转成
category类型节省内存。 - 尽量在
groupby之前先过滤掉无效行,减少参与计算的数据量。
实测四万行数据,优化前后速度差异不大,但到三十万行时,只读指定列这一步就能让导入时间减少差不多一半。
7. 从数据到决策:这套系统怎么发挥实际价值
最后再分享一点系统之外的体会。搭建这套系统,技术难度其实算不上顶尖,真正有价值的是它让数据变成了可以指导行动的决策依据。系统上线后,运营同事第一次直观地看到:某个单品连续三个月下滑,但全年同期是上涨的——这种情况不是市场问题,而是商品本身竞争力在下降。采购同事也第一次拿到“月度畅销榜+库存余量”的联动表,补货从“凭感觉”变成“看数据”。其中一个效果很明显的动作是,根据秋冬进补季的销售规律,提前两个月锁定黄芪、当归等道地药材的采购量,那个Q4的毛利率比往年同期高了大概3个百分点。
如果你也想跑这套系统,建议不要只满足于把代码跑通,而是把你自己手上的销售数据导进去,看看能不能发现新的规律。数据分析和写代码最大的区别就在这里:代码有标准答案,数据没有。每一次跑批都可能看到不一样的结果,这才是这个项目最有意思的地方。