从手动复制到一键生成:我用Python+openpyxl搞定周报数据自动化

3 阅读1分钟

背景:被周报折磨的每个周一

我目前在一家电商公司负责部分业务的数据支持。我们团队每周一上午都要出一份销售周报,数据源是运营同事从后台导出的十几个Excel文件,每个文件对应一个销售渠道,比如天猫、京东、抖音等等。

我的任务就是把这些文件里的数据汇总到一张总表里,然后做一些简单的计算,比如各渠道销售额占比、环比增长等。听起来不复杂,对吧?但实际做起来特别磨人。每个文件的格式虽然大体一致,但总有细微差别:有的表头在第二行,有的在第三行;有的“销售额”列叫“GMV”;最头疼的是,有些文件里为了美观,用了大量的合并单元格。

手动操作流程是这样的:打开第一个文件,找到数据区域,复制,粘贴到总表;打开第二个文件,发现表头对不上,得手动调整列顺序;再打开第三个,遇到合并单元格,复制过来格式全乱了……一套流程下来,顺利的话也要一个半小时,要是中途分心贴错了行,又得从头检查。每个周一上午,我就像个没有感情的“Ctrl+C/V”机器。

我忍了三个月,终于在一个数据出错被领导点出后下定决心:必须用Python把这个过程自动化,把时间还给更有价值的分析工作。

问题分析:为什么不是pandas?

一开始,我理所当然地想到了pandaspd.read_excel()df.to_excel()多简单啊。我快速写了个脚本:

import pandas as pd
import os

all_data = []
folder_path = './sales_reports'

for file in os.listdir(folder_path):
    if file.endswith('.xlsx'):
        df = pd.read_excel(os.path.join(folder_path, file))
        all_data.append(df)

result = pd.concat(all_data, ignore_index=True)
result.to_excel('weekly_summary.xlsx', index=False)

跑了一下,直接报错,而且问题一大堆:

  1. 表头位置不一致pandas默认把第一行当表头,但有的文件第一行是标题“XX渠道销售数据”,第二行才是真正的列名。导致读进来的DataFrame列名乱七八糟。
  2. 合并单元格灾难pandas会直接把合并单元格的值放在左上角单元格,其他位置变成NaN。当我按行复制时,很多关键数据就丢失了。
  3. 格式丢失:我需要保留原文件中的数字格式(比如金额的千位分隔符)、字体颜色(标红的下滑数据)等,pandas完全做不到。
  4. 写入新问题:用pandas生成的Excel,有时候列宽异常,还得手动调整,并没有完全“自动化”。

我意识到,pandas适合处理“纯数据”,但对于这种需要保持原有格式、结构不那么规范的Excel文件,有点力不从心。我需要一个能更精细操作Excel单元格的库。

核心实现:选择openpyxl与设计流程

搜索之后,我锁定了openpyxl。它是一个专门读写.xlsx格式的库,可以精确到单元格级别进行读写,还能处理样式。虽然不能读老旧的.xls格式,但我们公司早就全面升级了,所以没问题。

我的自动化脚本需要完成以下几个核心步骤:

  1. 智能定位数据:自动跳过表头,找到每个文件数据开始的真实位置。
  2. 破解合并单元格:将合并区域的值“填充”到每一个单元格,保证数据不丢失。
  3. 统一列映射:建立渠道列名与总表标准列名的映射关系,比如把“GMV”、“销售金额”都映射到“销售额”。
  4. 带格式写入:将数据连同基本的格式(字体、边框、数字格式)写入总表。
  5. 简单计算:在总表中自动计算合计、占比等。

下面我就分步拆解实现过程。

1. 智能定位数据起始行

这是第一个关键点。我观察发现,所有有效数据的表头行都包含“日期”这个关键词。所以,我的策略是:遍历文件的前10行(足够覆盖所有情况),找到第一个包含“日期”的单元格所在的行,那一行就是表头,下一行就是数据起始行。

from openpyxl import load_workbook

def find_data_start_row(ws, header_keyword="日期"):
    """
    在工作表中查找包含指定关键词的表头行
    返回数据起始行号(即表头行号+1)
    """
    # 通常数据不会超过前20行开始
    for row in ws.iter_rows(min_row=1, max_row=20, max_col=10):
        for cell in row:
            if cell.value and header_keyword in str(cell.value):
                # 找到关键词,返回下一行作为数据开始
                return cell.row + 1
    # 如果没找到,默认从第2行开始(假设第一行是表头)
    return 2

这里有个坑cell.value可能是None,直接进行in判断会报错,所以必须先判断cell.value是否存在,再转换成字符串进行查找。

2. 破解合并单元格并建立列映射

这是最棘手的部分。openpyxlworksheet.merged_cells.ranges属性可以获取所有合并单元格的范围。我的思路是:先记录下每个合并范围的值,然后在读取数据时,如果遇到属于合并范围的单元格,就使用记录的值。

同时,我需要建立一个列名映射字典。我手动定义了一个标准列名列表,然后根据每个文件表头行的内容进行模糊匹配。

def get_column_mapping(ws, header_row_idx):
    """
    根据表头行,建立列索引到标准列名的映射。
    例如:{1: '日期', 2: '销售额', ...}
    """
    # 标准列名(总表使用的)
    standard_headers = ['日期', '渠道', '商品ID', '商品名称', '销售额', '订单量']
    mapping = {}
    
    header_row = ws[header_row_idx]
    
    for cell in header_row:
        if cell.value:
            header_text = str(cell.value).strip()
            # 模糊匹配:如果标准列名是当前表头的一部分,或反之,则匹配
            for std_header in standard_headers:
                if std_header in header_text or header_text in std_header:
                    mapping[cell.column] = std_header # cell.column是列索引,如1,2,3
                    break # 匹配到一个就跳出内层循环
    return mapping

注意这个细节cell.column返回的是从1开始的列索引(整数),而不是字母。这比用column_index_from_string转换更方便。

处理合并单元格的读取逻辑,我把它整合到了遍历数据的函数里:

def read_sheet_data(ws, data_start_row, col_mapping, merged_cell_values):
    """
    从指定行开始读取数据,应用列映射,并处理合并单元格值。
    返回一个列表,每个元素是一行数据的字典。
    """
    data = []
    # 获取最大行和最大列(有数据的区域)
    max_row = ws.max_row
    max_column = ws.max_column
    
    for row in range(data_start_row, max_row + 1):
        row_data = {v: None for v in col_mapping.values()} # 用标准列名初始化字典
        for col_idx, std_header in col_mapping.items():
            cell = ws.cell(row=row, column=col_idx)
            cell_value = cell.value
            
            # 关键!处理合并单元格:如果当前单元格在合并范围内,则取合并区域的值
            for merged_range in merged_cell_values:
                if cell.coordinate in merged_range:
                    cell_value = merged_cell_values[merged_range]
                    break
            
            row_data[std_header] = cell_value
        
        # 如果一行数据全为空,则跳过(可能是末尾的空行)
        if any(row_data.values()):
            # 补充渠道信息(可以从文件名解析,这里简化处理)
            row_data['渠道'] = os.path.basename(ws.parent.filepath).split('_')[0]
            data.append(row_data)
    return data

3. 带格式写入总表

数据都读出来并统一格式后,写入就相对简单了。我用openpyxl创建一个新的工作簿,先写入标准表头,并设置简单的样式(加粗、居中、背景色)。然后逐行写入数据,给数值列加上千位分隔符的数字格式。

from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter

def write_summary(data_list, output_path='周报汇总.xlsx'):
    """将整理好的数据列表写入新的Excel文件"""
    from openpyxl import Workbook
    wb = Workbook()
    ws = wb.active
    ws.title = "销售周报汇总"
    
    # 定义标准表头
    headers = ['日期', '渠道', '商品ID', '商品名称', '销售额', '订单量']
    
    # 设置表头样式
    header_font = Font(bold=True, color="FFFFFF")
    header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
    alignment = Alignment(horizontal="center", vertical="center")
    thin_border = Border(left=Side(style='thin'), right=Side(style='thin'),
                         top=Side(style='thin'), bottom=Side(style='thin'))
    
    # 写入表头
    for col_idx, header in enumerate(headers, start=1):
        cell = ws.cell(row=1, column=col_idx, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.alignment = alignment
        cell.border = thin_border
    
    # 写入数据
    row_idx = 2
    for row_data in data_list:
        for col_idx, header in enumerate(headers, start=1):
            cell = ws.cell(row=row_idx, column=col_idx, value=row_data.get(header))
            cell.border = thin_border
            # 给销售额和订单量列设置数字格式
            if header in ['销售额', '订单量'] and isinstance(cell.value, (int, float)):
                if header == '销售额':
                    cell.number_format = '#,##0.00' # 千位分隔符,两位小数
                else:
                    cell.number_format = '#,##0'
        row_idx += 1
    
    # 自动调整列宽(近似)
    for col in ws.columns:
        max_length = 0
        column_letter = get_column_letter(col[0].column)
        for cell in col:
            try:
                if len(str(cell.value)) > max_length:
                    max_length = len(str(cell.value))
            except:
                pass
        adjusted_width = min(max_length + 2, 50) # 设置一个最大宽度
        ws.column_dimensions[column_letter].width = adjusted_width
    
    wb.save(output_path)
    print(f"汇总完成!文件已保存至:{output_path}")

这里有个坑openpyxl的自动调整列宽ws.column_dimensions[column_letter].bestFit并不总是可靠,我采用了计算每列最大字符长度的方法,虽然简单但效果不错。注意要捕获str(cell.value)的异常,因为cell.value可能是None

4. 串联主流程与简单计算

最后,我把所有功能串联起来,并在写入数据后,在总表末尾添加两行:一行计算各渠道销售总额,一行计算占比。

# 在主函数中,读取所有文件数据后
all_data = []
for file_path in excel_files:
    wb = load_workbook(file_path, data_only=True) # data_only=True只读值,不读公式
    ws = wb.active
    ws.filepath = file_path # 给ws对象加个属性,方便后面取文件名
    
    # 找到表头行和数据起始行
    header_row_idx = find_data_start_row(ws) - 1
    data_start_row = header_row_idx + 1
    
    # 获取列映射
    col_mapping = get_column_mapping(ws, header_row_idx)
    if not col_mapping:
        print(f"警告:在文件 {file_path} 中未找到匹配的表头,跳过。")
        continue
    
    # 预处理合并单元格的值
    merged_cell_values = {}
    for merged_range in ws.merged_cells.ranges:
        # 获取合并区域左上角单元格的值
        top_left_cell = ws[merged_range.min_row][merged_range.min_col - 1] # 注意索引转换
        merged_cell_values[merged_range] = top_left_cell.value
    
    # 读取数据
    sheet_data = read_sheet_data(ws, data_start_row, col_mapping, merged_cell_values)
    all_data.extend(sheet_data)

# 写入汇总文件
write_summary(all_data)

# 在write_summary函数内部,数据写入后,可以添加汇总行
# ... 数据写入循环之后 ...
summary_row = row_idx + 1
ws.cell(row=summary_row, column=4, value="渠道总计").font = Font(bold=True)
# 使用公式计算每个渠道的销售额总和(假设渠道名列是B,销售额列是E)
# 这里简化,实际可以按渠道分组计算
ws.cell(row=summary_row, column=5, value=f"=SUMIF(B2:B{row_idx}, B{summary_row}, E2:E{row_idx})")

完整代码

"""
Excel销售周报自动化汇总脚本
作者:实战踩坑记录
功能:自动读取多个渠道销售Excel,统一格式后汇总至一个文件。
"""

import os
from openpyxl import load_workbook, Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter

def find_data_start_row(ws, header_keyword="日期"):
    """定位数据起始行"""
    for row in ws.iter_rows(min_row=1, max_row=20, max_col=10):
        for cell in row:
            if cell.value and header_keyword in str(cell.value):
                return cell.row + 1
    return 2

def get_column_mapping(ws, header_row_idx):
    """建立列索引到标准列名的映射"""
    standard_headers = ['日期', '渠道', '商品ID', '商品名称', '销售额', '订单量']
    mapping = {}
    header_row = ws[header_row_idx]
    for cell in header_row:
        if cell.value:
            header_text = str(cell.value).strip()
            for std_header in standard_headers:
                if std_header in header_text or header_text in std_header:
                    mapping[cell.column] = std_header
                    break
    return mapping

def read_sheet_data(ws, data_start_row, col_mapping, merged_cell_values):
    """读取单文件数据,处理合并单元格"""
    data = []
    max_row = ws.max_row
    for row in range(data_start_row, max_row + 1):
        row_data = {v: None for v in col_mapping.values()}
        for col_idx, std_header in col_mapping.items():
            cell = ws.cell(row=row, column=col_idx)
            cell_value = cell.value
            # 处理合并单元格
            for merged_range, merged_value in merged_cell_values.items():
                if cell.coordinate in merged_range:
                    cell_value = merged_value
                    break
            row_data[std_header] = cell_value
        if any(row_data.values()):
            # 简单从文件名提取渠道名,可根据实际情况修改
            filename = os.path.basename(ws.parent.filepath)
            channel = filename.split('_')[0] if '_' in filename else filename.replace('.xlsx', '')
            row_data['渠道'] = channel
            data.append(row_data)
    return data

def write_summary(data_list, output_path='周报汇总.xlsx'):
    """写入汇总文件并添加格式"""
    wb = Workbook()
    ws = wb.active
    ws.title = "销售周报汇总"
    
    headers = ['日期', '渠道', '商品ID', '商品名称', '销售额', '订单量']
    # 样式定义
    header_font = Font(bold=True, color="FFFFFF")
    header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
    align_center = Alignment(horizontal="center", vertical="center")
    thin_border = Border(left=Side(style='thin'), right=Side(style='thin'),
                         top=Side(style='thin'), bottom=Side(style='thin'))
    
    # 写表头
    for col_idx, header in enumerate(headers, start=1):
        cell = ws.cell(row=1, column=col_idx, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.alignment = align_center
        cell.border = thin_border
    
    # 写数据
    row_idx = 2
    for row_data in data_list:
        for col_idx, header in enumerate(headers, start=1):
            cell = ws.cell(row=row_idx, column=col_idx, value=row_data.get(header))
            cell.border = thin_border
            if header in ['销售额', '订单量'] and isinstance(cell.value, (int, float)):
                cell.number_format = '#,##0.00' if header == '销售额' else '#,##0'
        row_idx += 1
    
    # 调整列宽
    for col in ws.columns:
        max_len = 0
        col_letter = get_column_letter(col[0].column)
        for cell in col:
            try:
                cell_len = len(str(cell.value))
                if cell_len > max_len:
                    max_len = cell_len
            except:
                pass
        adjusted_width = min(max_len + 2, 50)
        ws.column_dimensions[col_letter].width = adjusted_width
    
    wb.save(output_path)
    print(f"✅ 汇总完成!文件已保存至:{output_path}")

def main():
    # 配置路径
    reports_folder = './sales_reports'  # 存放所有渠道Excel文件的文件夹
    output_file = './周报汇总.xlsx'
    
    if not os.path.exists(reports_folder):
        print(f"错误:文件夹 '{reports_folder}' 不存在。")
        return
    
    all_data = []
    excel_files = [os.path.join(reports_folder, f) for f in os.listdir(reports_folder) 
                   if f.endswith(('.xlsx', '.xlsm'))]
    
    if not excel_files:
        print("未找到Excel文件。")
        return
    
    for file_path in excel_files:
        print(f"正在处理:{os.path.basename(file_path)}")
        try:
            wb = load_workbook(file_path, data_only=True)
            ws = wb.active
            ws.parent.filepath = file_path  # 临时存储路径
            
            header_row_idx = find_data_start_row(ws) - 1
            data_start_row = header_row_idx + 1
            
            col_mapping = get_column_mapping(ws, header_row_idx)
            if not col_mapping:
                print(f"  ⚠️  跳过,未识别到表头。")
                continue
            
            # 处理合并单元格
            merged_values = {}
            for merged_range in ws.merged_cells.ranges:
                top_left_cell = ws[merged_range.min_row][merged_range.min_col - 1]
                merged_values[merged_range] = top_left_cell.value
            
            sheet_data = read_sheet_data(ws, data_start_row, col_mapping, merged_values)
            all_data.extend(sheet_data)
            print(f"  ✅ 读取到 {len(sheet_data)} 行数据。")
            
        except Exception as e:
            print(f"  ❌ 处理文件时出错:{e}")
    
    if all_data:
        write_summary(all_data, output_file)
        print(f"总计处理 {len(excel_files)} 个文件,合并 {len(all_data)} 行数据。")
    else:
        print("未读取到任何有效数据。")

if __name__ == "__main__":
    main()

踩坑记录

  1. data_only=True 的误解:一开始我没加这个参数,结果有些单元格值是公式(如=SUM(A1:A10)),读出来就是公式字符串本身,而不是计算结果。加上data_only=True后,openpyxl会读取上次Excel保存时计算好的值。但要注意,如果文件从未被Excel计算保存过,读出的公式值可能是None

  2. 合并单元格的坐标判断cell.coordinate返回的是字符串(如'A1'),而merged_range是一个CellRange对象。我最初直接用cell.coordinate == merged_range,永远为False。正确做法是使用cell.coordinate in merged_rangeCellRange对象支持in操作符来判断坐标是否在其范围内。

  3. 列索引与列字母的混淆openpyxl中,cell.column返回整数索引(1-based),cell.column_letter返回字母。在遍历和存储映射关系时,用整数索引更不容易出错。但在设置列宽ws.column_dimensions[column_letter]时,又必须用字母。这个转换需要用get_column_letter()函数。

  4. 中文路径或文件名报错:在Windows上,如果文件路径或工作表名称包含中文,有时会报编码相关的错误。一个稳妥的做法是使用os.path模块来构建路径,并确保Python脚本文件本身保存为UTF-8编码。如果问题依旧,可以尝试在文件路径字符串前加r(原始字符串)防止转义。

小结

这个自动化脚本把我每周一上午的“体力活”压缩到了3分钟以内,而且完全避免了手动操作可能带来的错误。核心收获是:处理非标准格式的Excel文件,openpyxl这种单元格级操作的库比pandas更灵活、更强大。下一步可以考虑加入邮件自动发送功能,或者用PyInstaller打包成可执行文件,分享给不会编程的同事使用。