《出版社物流WMS智能调度实战(四)》:从表到图 | WMS销售数据日汇总表可视化实战

📖 《出版社物流WMS智能调度实战》系列文章
环境准备篇:Windows/Linux双环境搭建指南 | 第一篇:架构升级 | 第二篇:开发排坑 | 第三篇:运维监控 | 第四篇:本文(销售数据可视化) | 第五篇:预测结果可视化

📌 前置阅读:本文是系列第四篇,建议先阅读环境准备篇完成服务器搭建,以及前三篇了解架构、开发和运维,再进入本篇的可视化实战。


摘要

销售数据日汇总表 WMS_SALES_DAILY_AGG 记录了出版社仓库每天的真实销售数据。如何从这些数字中快速看清业务全貌?本文用五张典型图表展示核心运营指标,所有图表均来自真实数据(已脱敏)。非技术人员可直接阅读图表及解读技术人员可在附录中找到完整的 Python 代码,一键生成交互式 HTML 报表,并支持钉钉/邮件自动推送。阅读本文约 15 分钟,代码可直接运行。


一、销售日汇总表能回答哪些问题?

业务问题 对应图表 用处
最近一个月销量走势如何? 日销售趋势折线图 判断淡旺季、促销效果
哪些书卖得最好? 畅销图书 Top 20 指导补货、调整库存位置
大多数订单用什么物流? 物流方式订单量 优化物流合同、降低成本
哪些省份订单多? 省份销量热力图 考虑区域分仓
促销活动有效吗? 折扣率 vs 销量散点图 评估促销ROI

下面我们直接看图表(数据为脱敏示例,实际运行时替换为真实数据)。


二、图表展示与解读

2.1 日销售趋势(折线图)

日销售趋势图

📌 解读

  • 横轴为日期,纵轴为每日销售托数(整托数量)和件数(含拆零)。
  • 从图中可以清晰看出每周上半周有一个销售高峰(可能是季节性),下半周之后回落。
  • 仓库经理(物流调度)可据此提前安排加班或调整出库策略(下半周做好下周的补货移位等工作)。

2.2 畅销图书 Top 20(柱状图)

畅销图书 Top 20

📌 解读

  • 展示销量最高的 20 个图书编码(实际报表中可关联商品表显示书名)。
  • 头部图书占据了约 35% 的销量,应优先将这些图书放在快速区,确保整托出库。
  • 采购部门(业务部及物流调度)可关注这些图书的库存,及时沟通采购或图书调拨等补充库存工作,避免缺货。

2.3 物流方式订单量

物流方式订单量

📌 解读

  • 汽运是主要物流方式。
  • 如果快递占比持续上升,可以考虑与快递公司签订更优惠的长期合同。

2.4 销量 Top 10 省份

省份销量排行

📌 解读

  • 北京、天津、广东是销量前三的省份。
  • 如果条件允许,可以考虑在这些省份设立区域仓,缩短配送时间。

2.5 折扣率 vs 销量

折扣率 vs 销量

📌 解读

  • 每个点代表一天,横轴为当日平均折扣率(0=原价,0.2=8折),纵轴为日销量(件)。蓝色虚线为线性趋势线。
  • 从图中可以看出,折扣率在 0.5-0.6(即 4-5 折)时销量明显提升,但折扣超过 0.7(3 折以下)后销量并未成比例增长,说明折让已经过度。
  • 业务营销团队可根据此图优化折扣力度。

三、自动化与推送

以上图表如何每天自动生成并推送给团队?技术流程如下:

  1. 数据读取:从 WMS_SALES_DAILY_AGG 表读取最近 30 天数据。
  2. 绘图:使用 Python 的 matplotlib(静态图)和 plotly(交互式图)生成图表。
  3. 生成 HTML 报告:将所有图表整合到一个 HTML 文件,支持手机/电脑查看。
  4. 定时执行:通过 crontab 每天早晨 8:30 自动运行脚本。
  5. 推送:将生成的 HTML 报告上传至公司内部静态服务器,并通过钉钉机器人发送链接;或直接发送邮件。

详细代码见附录


四、附录:Python 代码(供技术人员使用)

4.1 环境依赖

pip install pandas matplotlib seaborn plotly jinja2 requests

4.2 完整脚本 generate_sales_report.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
from datetime import datetime, timedelta
from db_utils import target_db  # 复用系列中的数据库模块
import base64
from io import BytesIO
from jinja2 import Template
import requests
import sys
import os

# ==================== 中文字体设置 ====================
if sys.platform.startswith('win'):
    plt.rcParams['font.sans-serif'] = ['Microsoft YaHei']
    plt.rcParams['axes.unicode_minus'] = False
else:
    plt.rcParams['font.sans-serif'] = ['WenQuanYi Zen Hei', 'Noto Sans CJK SC']
    plt.rcParams['axes.unicode_minus'] = False
sns.set_style("whitegrid")
# ==================== 数据加载 ====================
def load_sales_data(start_date, end_date):
    sql = f"""
    SELECT sale_date, item_id, total_tuo, total_qty, total_amount,
           order_count, customer_count, avg_discount_rate,
           main_delivery_type, main_province, publisher_code
    FROM WMS_SALES_DAILY_AGG
    WHERE sale_date BETWEEN :start AND :end
    """
    return target_db.query(sql, {'start': start_date, 'end': end_date})

# ==================== 静态图(matplotlib)转 base64 ====================
def fig_to_base64(fig):
    buf = BytesIO()
    fig.savefig(buf, format='png', bbox_inches='tight')
    buf.seek(0)
    return base64.b64encode(buf.read()).decode('utf-8')

# ==================== 图表生成 ====================
def plot_daily_trend(df):
    daily = df.groupby('sale_date').agg({'total_tuo': 'sum', 'total_qty': 'sum'}).reset_index()
    fig, ax = plt.subplots(figsize=(12, 5))
    ax.plot(daily['sale_date'], daily['total_tuo'], marker='o', label='托数')
    ax.plot(daily['sale_date'], daily['total_qty'], marker='s', label='件数')
    ax.set_xlabel('日期'); ax.set_ylabel('销量'); ax.set_title('日销售趋势')
    ax.legend(); plt.xticks(rotation=45); plt.tight_layout()
    return fig

def plot_top_books(df, top_n=20):
    book_sales = df.groupby('item_id')['total_tuo'].sum().reset_index()
    book_sales = book_sales.sort_values('total_tuo', ascending=False).head(top_n)
    fig = px.bar(book_sales, x='item_id', y='total_tuo', title=f'畅销图书 Top {top_n}(托数)')
    return fig

def plot_delivery_method(df):
    delivery = df.groupby('main_delivery_type')['order_count'].sum().reset_index()
    fig = px.bar(delivery, x='main_delivery_type', y='order_count', title='各物流方式订单量')
    return fig

def plot_province_heatmap(df):
    province = df.groupby('main_province')['total_qty'].sum().reset_index()
    province = province.sort_values('total_qty', ascending=False).head(10)
    fig = px.bar(province, x='main_province', y='total_qty', title='销量 Top 10 省份')
    return fig

def plot_discount_vs_sales(df):
    daily = df.groupby('sale_date').agg({'avg_discount_rate': 'mean', 'total_qty': 'sum'}).reset_index()
    fig = px.scatter(daily, x='avg_discount_rate', y='total_qty', text='sale_date',
                     title='折扣率与销量关系', labels={'avg_discount_rate': '平均折扣率', 'total_qty': '日销量(件)'})
    return fig

# ==================== 生成 HTML 报告 ====================
def generate_report(start_date=None, end_date=None):
    if start_date is None:
        end_date = datetime.now().date() - timedelta(days=1)
        start_date = end_date - timedelta(days=29)
    df = load_sales_data(start_date, end_date)
    if df.empty:
        print("无数据")
        return

    # 生成图表
    fig1 = plot_daily_trend(df)
    fig2 = plot_top_books(df)
    fig4 = plot_delivery_method(df)
    fig5 = plot_province_heatmap(df)
    fig6 = plot_discount_vs_sales(df)

    daily_trend_img = fig_to_base64(fig1)
    top_books_html = fig2.to_html(full_html=False)
    delivery_html = fig4.to_html(full_html=False)
    province_html = fig5.to_html(full_html=False)
    discount_html = fig6.to_html(full_html=False)

    html_template = """
    <html>
    <head><meta charset="UTF-8"><title>销售日汇总报表</title></head>
    <body>
        <h1>销售日汇总报表</h1>
        <p>统计区间: {{ start_date }} 至 {{ end_date }}</p>
        <h2>1. 日销售趋势</h2>
        <img src="data:image/png;base64,{{ daily_trend_img }}" style="width:100%"/>
        <h2>2. 畅销图书 Top 20</h2>
        {{ top_books_html }}
        <h2>3. 物流方式订单量</h2>
        {{ delivery_html }}
        <h2>4. 销量 Top 10 省份</h2>
        {{ province_html }}
        <h2>5. 折扣率 vs 销量</h2>
        {{ discount_html }}
    </body>
    </html>
    """
    template = Template(html_template)
    html_content = template.render(
        start_date=start_date, end_date=end_date,
        daily_trend_img=daily_trend_img,
        top_books_html=top_books_html,
        delivery_html=delivery_html,
        province_html=province_html,
        discount_html=discount_html
    )
    filename = f"sales_report_{datetime.now().strftime('%Y%m%d')}.html"
    with open(filename, "w", encoding='utf-8') as f:
        f.write(html_content)
    print(f"报表已生成: {filename}")
    return filename

# ==================== 推送钉钉 ====================
def send_dingtalk(file_url, webhook):
    data = {
        "msgtype": "text",
        "text": {"content": f"销售日报已生成,点击查看:{file_url}"}
    }
    requests.post(webhook, json=data)

if __name__ == "__main__":
    report_file = generate_report()
    # 若需要推送到钉钉,取消下一行注释并配置 webhook 和文件访问 URL
    # send_dingtalk("https://your-server.com/" + report_file, "your_webhook")
    # 如发送邮件等功能,增加发送邮件函数后在此调用即可。

4.3 定时任务(crontab)

# 每天 8:30 生成昨日销售报表
30 8 * * * cd /opt/wms_ml && source venv/bin/activate && python generate_sales_report.py >> logs/sales_report.log 2>&1

⚠️ 请根据实际安装路径修改 /opt/wms_ml 和虚拟环境名称。


五、常见问题与排坑

问题 解决方案
matplotlib 中文字体显示方框 设置 plt.rcParams['font.sans-serif'] = ['Microsoft YaHei'](Windows)或 ['WenQuanYi Zen Hei'](Linux)
DPI-1047 找不到 Oracle 客户端 注释掉厚模式代码,改用 Thin 模式;或正确安装 Oracle Instant Client
生成的 HTML 中部分图表为空白 检查 plotly 是否正常安装,尝试在浏览器中打开 F12 查看控制台错误
查询结果为空 检查销售日汇总表 WMS_SALES_DAILY_AGG 是否有数据,以及日期范围是否正确
to_html(full_html=False) 样式缺失 可尝试使用 fig.to_html(include_plotlyjs='cdn') 并保留完整 HTML 头
crontab 执行脚本无日志 在 crontab 中使用绝对路径,并确保 logs 目录有写权限

六、总结

本文用五张图表展示了销售数据日汇总表的核心价值,非技术人员可直接看图决策;技术人员可按附录代码实现自动化报表生成与推送。让数据从“躺在数据库”变成“摆在桌面”,真正驱动仓库精细化运营。

📖 系列回顾

下期预告:第五篇《预测结果可视化与模型效果监控》已发布,围绕 WMS_ML_FORECAST_TUO 表制作预测 vs 实际对比图、误差分析看板。第六篇《自助式 BI 集成》将介绍如何将销售日汇总表和预测结果表接入 Power BI / FineReport,让业务人员自己拖拽分析,敬请期待。

📌 资料包领取:本文配套完整代码、建表SQL、调度脚本已整理,关注后私信“销售可视化”即可获取。
互动:你的仓库最关心哪些销售指标?欢迎在评论区分享,我会抽取典型问题在下期解答。

更多推荐