首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用 Python 批量生成销售日报,告别每天复制粘贴

用 Python 批量生成销售日报,告别每天复制粘贴

原创
作者头像
小白学大数据
发布2026-08-12 16:55:47
发布2026-08-12 16:55:47
970
举报

销售日报需要汇总各渠道的订单数、销售额和客单价。渠道超过十个以后,手工把数据从各平台抄进同一张 Excel 就成了明显负担:耗时、易错、且无法追溯数据来源。以 11 个渠道、日均 42 分钟手工汇总估算,每月仅此一项就占用约 15 个小时。本文用一个可落地的 Python 脚本,把这份日报改成每天定时自动生成。流程拆成取数、归一化、算指标、写 Excel、调度五步,代码可直接复用,数据源与代理配置集中管理,渠道字段变动时只改对应函数。先说清日报的结构:左侧各渠道一行,右侧是当日订单数、销售额、客单价,末尾合计并附环比。格式稳定是自动化的前提,若格式频繁变动,脚本反而增加维护成本。先把事情拆开一份"日报"其实就三件事。第一,数据从哪儿来。第二,拿回来怎么算。第三,算完怎么落盘成 Excel 并发出去。这三步我分开写,后面渠道改字段或者老板要加指标,都不用动全身。有人会问,为什么不直接用 pandas 一口气读库写表完事。因为我们的情况是混合数据源,没法一刀切。公司自有订单系统在公司内网,直接连库拉就行。另外两个渠道的销量要走平台开放接口,而这个接口按出口 IP 限流,办公室那条固定 IP 请求两轮就被封半小时。这两类数据源的取数逻辑完全不同,硬塞一个函数里只会越写越乱。也有人推荐直接用 Excel 的 Power Query。试过,图形化界面点起来爽,但渠道一多,每个查询的刷新顺序、依赖关系全藏在界面背后,出了错没法 diff,也没法进版本库。脚本丑是丑,但每一行都在眼前,新人接手看代码就行,不用我坐在旁边讲半小时。抓数据,并且要能重试网络这种事你控制不了,别指望一次就通。接口超时、平台抖一下、代理偶尔抽风,都是常态。我用了 tenacity 做重试,最多试三次,失败之间退避等待,避免一拥而上把接口打挂。报表自动化为什么绕不开代理先把背景讲透。日报要拉十几个渠道,其中有几个是淘宝、抖音这类开放平台,它们的订单接口都带反爬。办公室那条固定出口 IP,请求两轮就被限流半小时。我一开始加了 sleep 退避,没用,平台认的是 IP 不是频率。后来接了亿牛云的动态转发代理(隧道代理)。它背后是一个很大的 IP 池,只配一个固定入口,每次转发自动换出口 IP,对目标平台来说每次请求都来自不同地方,而且是匿名的,拉数据跟正常访问没两样。我不用自己维护 IP 池,某个 IP 被封也不会全盘停摆。日报一天几十兆流量,动态转发按月付,成本比自己租服务器搭池子低很多,这也是我没自己养 IP 的原因。代理的鉴权是用户名加密码,格式是 http://用户名:密码@入口域名:端口。用户名里通常带订单号和地区参数,具体以后台接入串为准,别照抄网上的例子。动态转发一般要求把调用方出口 IP 加进白名单,否则拒绝连接。我第一次配完连不上,查了半天是忘绑白名单,这种坑踩一次就记住了。

代码语言:txt
复制
from dataclasses import dataclass
from datetime import date
from typing import Optional
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential


@dataclass
class YiniuProxy:
    """动态转发代理配置,集中管理鉴权与入口。"""
    order_id: str
    secret: str
    host: str = "dynamic.ynyun-proxy.com"
    port: int = 8000
    region: str = "cn"

    @property
    def tunnel(self) -> str:
        # 用户名里带订单号与地区是代理的鉴权约定,按后台接入串填
        user = f"{self.order_id}-region-{self.region}"
        return f"http://{user}:{self.secret}@{self.host}:{self.port}"


PROXY = YiniuProxy(order_id="YOUR_ORDER", secret="YOUR_SECRET").tunnel


@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10))
def fetch_channel_orders(channel: str, day: date) -> list[dict]:
    """拉取单个渠道某天的订单原始数据。

    Args:
        channel: 渠道标识,例如 "taobao" 或 "douyin"
        day: 统计日期

    Returns:
        平台返回的原始订单字典列表

    Raises:
        httpx.HTTPError: 三次重试后仍失败
    """
    resp = httpx.get(
        f"https://api.example.com/{channel}/orders",
        params={"date": day.isoformat()},
        headers={"User-Agent": "Mozilla/5.0 (compatible; daily-report/1.0)"},
        proxies={"http://": PROXY, "https://": PROXY},
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()["data"]

抓取我用的是 httpx 而不是 requests,看中的就是它的异步能力。渠道一多,十几个请求串行太慢,改成 asyncio 并发之后,隧道代理给每个请求分配不同出口 IP,天然适合并发跑,日报生成时间从一分钟压到了十秒出头。有个坑得提醒:平台的开放接口大多校验 User-Agent,不带就直接返回 403,第一次跑不通我查了半小时才定位到。还有一件事要提前想:十几个渠道里只要有一个挂了,整份日报就废了吗?我的做法是每个渠道单独 try,失败的记进日志并跳过,最后如果全部失败才整体告警。这样某个平台凌晨维护,不影响其他渠道的日报照常出来。

代码语言:txt
复制
def fetch_all(channels: list[str], day: date) -> list[SalesRecord]:
    records: list[SalesRecord] = []
    for ch in channels:
        try:
            raw = fetch_channel_orders(ch, day)
            records.extend(normalize(raw, ch, day))
        except Exception as exc:  # 单渠道失败不应拖垮整份日报
            logging.error("渠道 %s 拉取失败: %s", ch, exc)
    return records

用 dataclass 把一行日报钉死拿到原始数据先别急着算,先定义清楚"一行日报长什么样"。我习惯用 dataclass 把字段和类型钉死,后面无论是存库还是出 Excel,都围绕这个结构转。类型不对解释器当场报错,比跑起来才发现某列是 None 强太多。比起直接用 dict,dataclass 还有个好处:你能把客单价这种派生字段写成 property,调用方拿到的永远是算好的值,不用到处重复那段除法。

代码语言:txt
复制
@dataclass
class SalesRecord:
    channel: str
    order_date: date
    order_count: int
    total_amount: float
    customer_count: int

    @property
    def avg_order_value(self) -> float:
        if self.customer_count == 0:
            return 0.0
        return self.total_amount / self.customer_count

不同渠道返回的字段名天差地别,有的叫 order_cnt,有的叫 pay_count,金额字段有的带单位后缀。这一步是纯体力活,但集中在一个归一化函数里,以后改起来不头疼。

代码语言:txt
复制
def normalize(raw: list[dict], channel: str, day: date) -> list[SalesRecord]:
    """把平台原始订单转成统一的 SalesRecord。"""
    records: list[SalesRecord] = []
    for item in raw:
        records.append(
            SalesRecord(
                channel=channel,
                order_date=day,
                order_count=int(item["order_cnt"]),
                total_amount=float(item["pay_amount"]),
                customer_count=int(item["buyer_cnt"]),
            )
        )
    return records

算指标,逻辑摊开在眼前总销售额求和,客单价用总额除以人数,环比拿今天和昨天的差额除昨天。数据量小的时候我更偏向手写而不是上 pandas,逻辑摊开在眼前,新人也能一眼看懂。等哪天单渠道一天几万单,再考虑换 pandas 也不迟。除以昨天那段一定要先判断昨天是不是零,否则新渠道上线第一天就会抛除零异常,我踩过这个坑。

落盘成 Excel出文件用 openpyxl。样式最容易写成一坨,我把它单独抽成一个函数,只负责写数据和设置表头加粗、列宽自适应。这样哪天要把日报改成横向排版,只动这个函数。金额这类字段,openpyxl 默认按浮点存,Excel 里会显示成一长串小数或者科学计数法。我在写之前用 round 保留两位小数,需要千分位的话再套一个 f"{x:,.2f}"。老板看报表不爱看 12345.6789,他要看 12,345.68。这种细节代码不会替你想,得你自己定。日报我按日期命名存本地,顺手也传一份到共享盘,三个月攒下九十多个文件,哪天要拉趋势直接读回来,连数据库都不用碰。

上线前先测一遍脚本写完别直接丢服务器上。我本地用 pytest 写了三个用例:一个造几条假 SalesRecord 验 aggregate 的环比计算,一个验 normalize 在字段缺失时不崩,一个验 write_report 产出的文件能被 openpyxl 重新打开。跑通再上线,比凌晨被告警叫起来改 bug 舒服得多。这三个用例加起来不到四十行,但每次改归一化逻辑之前跑一遍,心里有底。

代码语言:txt
复制
from datetime import date
import pytest
from openpyxl import load_workbook
def test_aggregate_mom():
 today = [SalesRecord("self", date(2026, 8, 1), 10, 1000.0, 8)]
 prev = [SalesRecord("self", date(2026, 7, 31), 10, 500.0, 8)]
 result = aggregate(today, prev)
 assert result["mom"] == 100.0
def test_normalize_missing_field():
 raw = [{"order_cnt": "3", "pay_amount": "300", "buyer_cnt": "2"}]
 assert len(normalize(raw, "self", date(2026, 8, 1))) == 1
def test_write_report_readable(tmp_path):
 recs = [SalesRecord("self", date(2026, 8, 1), 3, 300.0, 2)]
 p = tmp_path / "r.xlsx"
 write_report(str(p), recs, aggregate(recs))
 assert load_workbook(p)["销售日报"]["A1"].value == "渠道"

串起来,并留好日志main 里就四步:拉数据、归一化、算指标、写文件。再加一层 logging,哪天报表没出来,能回看是卡在抓取还是卡在写盘。我真实遇到过一次:某渠道接口凌晨升级,返回的字段改了名,归一化函数直接抛错。多亏有日志,我早上看了一眼就知道是哪一个渠道,十分钟修完补跑,没耽误早会。光写日志不够,失败了得有人知道。我在 main 外层包了一层 try,任何渠道连续失败就发一条钉钉告警到运维群。第一次收到告警是某个渠道证书过期,我比平台客服早发现,挺有成就感的。代理侧也不是永远稳,有回代理入口做了维护,日志里一排连接拒绝,我看了一眼就知道不是我代码的问题,等它恢复就行,没白折腾去查自己的脚本。

代码语言:txt
复制
import logging
logging.basicConfig(filename="report.log", level=logging.INFO,
 format="%(asctime)s %(levelname)s %(message)s")
def main() -> None:
 day = date.today()
 all_records: list[SalesRecord] = fetch_all(["taobao", "douyin", "self"], day)
 if not all_records:
 logging.error("全部渠道拉取失败,终止生成")
 return
 summary = aggregate(all_records)
 write_report(f"sales_{day.isoformat()}.xlsx", all_records, summary)
 logging.info("日报已生成:%s", summary)

定时跑我用 schedule 库,比 crontab 好懂,部署在 Windows 上也零配置。要是跑在 Linux 服务器上,我更推荐直接交给 crontab,不用常驻一个进程占着内存。

代码语言:txt
复制
import schedule
import time
schedule.every().day.at("07:00").do(main)
while True:
 schedule.run_pending()
 time.sleep(30)

发出去那步我接了公司 SMTP,日报作为附件发到指定邮箱。你们团队用钉钉或者企业微信也行,把写文件换成调对应的机器人接口即可,改动很小。三个月下来的体会脚本上线三个月,日报没漏过一天。我估摸每天省下至少半小时,更值钱的是不再有抄错行的低级事故,老板追问某个数字时我也能从日志里翻出来源。以前那种"这个数字好像不太对但又说不清哪不对"的心虚感,没了。这脚本第一版其实很丑,所有逻辑堆在一个文件里,连配置文件都没有。它能跑,我就先让它跑了三个月。等真的稳定了,才把渠道列表、代理配置挪进一个 config 文件。我建议大家都这么干:先让能用的版本上线,再谈优雅。追求一步到位容易卡在抽象设计上,最后啥也没上线。也不是所有报表都该自动化。那种只出一次的季度复盘、字段随时在变的临时分析,写脚本的维护成本可能比手工还高。判断标准很简单:这份报表接下来三个月还会不会以同样的格式出现。会,就值得自动化;不会,老老实实复制粘贴。维护成本真实存在,渠道字段平均两个月变一次,每次十分钟,对比每天省下的半小时,这笔账怎么算都划算。如果团队不止你一个人要看,把脚本和 config 一起丢进 git,谁改了什么一目了然,比把 Excel 传来传去强。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档