|
@@ -0,0 +1,537 @@
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+# Author : Charley
|
|
|
|
|
+# Python : 3.12.10
|
|
|
|
|
+# Date : 2026/08/14
|
|
|
|
|
+"""超级仓库 SuperVault · 已售统计报告(Excel 单 Sheet 分区)。
|
|
|
|
|
+
|
|
|
|
|
+平台为自营(全站视为一个商家),故没有「商家维度」,改以「主播」为二级拆分维度。
|
|
|
|
|
+一个拼团商品 = 一个「组队」,故「每条明细」= 每个已售拼团商品。
|
|
|
|
|
+
|
|
|
|
|
+报告结构(自上而下单 Sheet 分区):
|
|
|
|
|
+ 一、平台汇总(整个自营平台累计)
|
|
|
|
|
+ 二、各主播汇总(每主播一行,按销售额倒序)
|
|
|
|
|
+ 三、各主播每条组队明细(按主播销售额倒序分块,每块内按总金额倒序)
|
|
|
|
|
+
|
|
|
|
|
+口径说明:
|
|
|
|
|
+ - 统计范围:默认 WIN_MODE="all",剔除 sold_count=0(未开卖)与 status 0/1/2/3(未开始/预售/
|
|
|
|
|
+ 进行中等未成交态),只统计 status 4/6/7/8/9/10/12 等有效成交态的团。
|
|
|
|
|
+ - 主播维度按 anchor_username 分组:同名不同 anchor_id(如两个「北京小周」)合并为同一位主播。
|
|
|
|
|
+ - 销售额 = SUM(sign_price * count)(单价 × 总份数 = 满仓总价;
|
|
|
|
|
+ 注意随机卡种/未售罄团会高于实收 total_price)。
|
|
|
|
|
+ - 成团数 = 该范围内的拼团商品数。
|
|
|
|
|
+ - 参与人数 = super_vault_report_record.user_name 去重(拆卡报告中卡用户,近似参团人头;
|
|
|
|
|
+ 仅覆盖有拆卡报告的商品,非真实买家,偏低)。平台口径跨全部商品去重;各主播口径按
|
|
|
|
|
+ 该主播商品单独去重,故各主播逐个相加(人次) ≥ 平台。
|
|
|
|
|
+ - 均拼单价 = 销售额 / 成团数
|
|
|
|
|
+ - 人均消费 = 销售额 / 参与人数(按中卡近似,偏高,仅供参考)
|
|
|
|
|
+ - 明细「参与人数(本团)」= 该团拆卡报告 user_name 去重(中卡近似),口径与汇总一致、拆到单团。
|
|
|
|
|
+ - 本平台无 buy_record(真实买家)与 onsale 进度快照表,故无「真实参与人数」与「进度里程碑」列。
|
|
|
|
|
+
|
|
|
|
|
+从项目根目录运行:python stats/super_vault_his_report.py(cwd=根目录,读根目录 application.yml)
|
|
|
|
|
+"""
|
|
|
|
|
+import os
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+# 把项目根目录加入 import 路径(与得卡报告一致:根目录留一份公共模块时可直接 import)
|
|
|
|
|
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
+from datetime import date, datetime
|
|
|
|
|
+
|
|
|
|
|
+import schedule
|
|
|
|
|
+from loguru import logger
|
|
|
|
|
+from openpyxl import Workbook
|
|
|
|
|
+from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
|
|
|
|
+from mysql_pool import MySQLConnectionPool
|
|
|
|
|
+
|
|
|
|
|
+# 日志:按天切分文件,保留 7 天(常驻定时运行)
|
|
|
|
|
+logger.remove()
|
|
|
|
|
+logger.add("./logs/super_vault_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
|
|
|
|
|
+ format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}", level="INFO", retention="7 day")
|
|
|
|
|
+
|
|
|
|
|
+# 企微发送:本项目暂无 auto_send_wx_msg 模块,默认关闭;日后接入企微机器人时置 True 并放置发送模块即可
|
|
|
|
|
+SEND_WECHAT = False
|
|
|
|
|
+
|
|
|
|
|
+OUT_PREFIX = "超级仓库已售统计报告" # 输出文件名前缀,后缀加窗口标签
|
|
|
|
|
+
|
|
|
|
|
+# 统计窗口模式:
|
|
|
|
|
+# "all" —— 全表已售商品累计,默认口径(不按 status/售罄过滤)
|
|
|
|
|
+# "daily" —— 成交锚点 COALESCE(completion_time, sell_time) 落在 [昨天17:00, 今天03:00]
|
|
|
|
|
+# "range" —— 显式区间 STAT_START ~ STAT_END(含两端),锚点同上
|
|
|
|
|
+WIN_MODE = "all"
|
|
|
|
|
+STAT_START = "2026-08-01 00:00:00" # WIN_MODE="range" 时生效
|
|
|
|
|
+STAT_END = "2026-09-01 00:00:00" # WIN_MODE="range" 时生效
|
|
|
|
|
+
|
|
|
|
|
+# 成交时间锚点:完成时间(completion_time,发货完成态才有) → 售罄成交时刻(sold_time,近期团几乎都有) →
|
|
|
|
|
+# 开售时间(sell_time) 依次兜底。多数待发货团无 completion_time 但有 sold_time,故成交时间覆盖率大幅提升。
|
|
|
|
|
+TS_ANCHOR = "COALESCE({a}.completion_time, {a}.sold_time, {a}.sell_time)"
|
|
|
|
|
+
|
|
|
|
|
+# 明细表列规格:(表头, 明细dict取值键, 是否千分位金额格式);取值键为「序号」时按行号自增填充。
|
|
|
|
|
+# 「参与人数(本团)」= 该团拆卡报告 user_name 去重(中卡近似),放在总金额后、开售时间前。
|
|
|
|
|
+DETAIL_COLS = [
|
|
|
|
|
+ ("序号", "序号", False), ("团名(商品标题)", "团名", False), ("编号", "编号", False),
|
|
|
|
|
+ ("卡种", "卡种", False), ("规格", "规格", False), ("单价", "单价", True),
|
|
|
|
|
+ ("总份数", "总份数", False), ("进度%", "进度", False), ("总金额", "总金额", True),
|
|
|
|
|
+ ("参与人数(本团)", "参与人数", False),
|
|
|
|
|
+ ("开售时间", "开售时间", False), ("成交时间", "成交时间", False), ("售卖时长", "售卖时长", False),
|
|
|
|
|
+]
|
|
|
|
|
+# 分区标题/大标题横向着色跨列数:取明细列数,保证蓝条覆盖整表宽
|
|
|
|
|
+SECTION_SPAN = len(DETAIL_COLS)
|
|
|
|
|
+# 汇总指标(平台/主播通用)
|
|
|
|
|
+SUMMARY_HEADERS = ["销售额", "成团数", "参与人数", "均拼单价", "人均消费"]
|
|
|
|
|
+
|
|
|
|
|
+# ---- 样式 ----
|
|
|
|
|
+FONT_TITLE = Font(bold=True, size=14)
|
|
|
|
|
+FONT_SECTION = Font(bold=True, size=12, color="FFFFFF")
|
|
|
|
|
+FONT_HEADER = Font(bold=True)
|
|
|
|
|
+FILL_SECTION = PatternFill("solid", fgColor="4472C4") # 分区标题:蓝底白字
|
|
|
|
|
+FILL_HEADER = PatternFill("solid", fgColor="D9E1F2") # 表头:浅蓝底
|
|
|
|
|
+FILL_SUBTOTAL = PatternFill("solid", fgColor="FCE4D6") # 汇总行:浅橙底
|
|
|
|
|
+THIN = Side(style="thin", color="BFBFBF")
|
|
|
|
|
+BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
|
|
|
|
+MONEY_FMT = "#,##0.00"
|
|
|
|
|
+ALIGN_LEFT = Alignment(horizontal="left", vertical="center") # 数值左对齐,避免宽列里右飘留大空白
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def win_clause(alias: str) -> str:
|
|
|
|
|
+ """按 WIN_MODE 生成某表别名的 WHERE 片段(供主查询与参与人数子查询共用)。
|
|
|
|
|
+
|
|
|
|
|
+ 统一先剔除 sold_count=0 的未开卖占位/预售团;默认 "all" 仅此一条过滤,
|
|
|
|
|
+ daily/range 再叠加成交锚点 COALESCE(completion_time, sell_time) 卡时间窗
|
|
|
|
|
+ (range 常量边界直接内联,STAT_START/STAT_END 为本文件常量、非外部输入,无注入风险)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ alias (str): 商品表别名(如 "p" / "pp")。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: WHERE 片段。
|
|
|
|
|
+ """
|
|
|
|
|
+ # 剔除未开卖(sold=0) 与 status 0/1/2/3(未开始/预售/进行中等未成交态),只留 4/6/7/8/9/10/12 等有效成交态
|
|
|
|
|
+ base = f"{alias}.sold_count > 0 AND {alias}.status NOT IN (0, 1, 2, 3)"
|
|
|
|
|
+ ts = TS_ANCHOR.format(a=alias)
|
|
|
|
|
+ if WIN_MODE == "daily":
|
|
|
|
|
+ return (f"{base} AND {ts} >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR"
|
|
|
|
|
+ f" AND {ts} <= CURDATE() + INTERVAL 3 HOUR")
|
|
|
|
|
+ if WIN_MODE == "range":
|
|
|
|
|
+ return f"{base} AND {ts} >= '{STAT_START}' AND {ts} <= '{STAT_END}'"
|
|
|
|
|
+ return base
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def get_window_label(pool) -> tuple[str, str]:
|
|
|
|
|
+ """取当前统计窗口的展示文案与文件名标签。
|
|
|
|
|
+
|
|
|
|
|
+ all 模式取全表成交锚点的时间跨度(排除 sell_time 未来占位脏数据,避免时间轴被污染)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ tuple[str, str]: (标题展示文案, 文件名日期标签)。
|
|
|
|
|
+ """
|
|
|
|
|
+ if WIN_MODE == "daily":
|
|
|
|
|
+ row = pool.select_all(
|
|
|
|
|
+ "SELECT (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR, CURDATE() + INTERVAL 3 HOUR")[0]
|
|
|
|
|
+ return f"每日成交窗 {row[0]} ~ {row[1]}", f"{date.today():%Y%m%d}"
|
|
|
|
|
+ if WIN_MODE == "range":
|
|
|
|
|
+ return f"成交区间 {STAT_START} ~ {STAT_END}", \
|
|
|
|
|
+ f"{STAT_START[:10].replace('-', '')}-{STAT_END[:10].replace('-', '')}"
|
|
|
|
|
+ # all:全表时间跨度(与统计口径一致:sold>0 且 status NOT IN(0,1,2,3);锚点 completion→sold→sell)
|
|
|
|
|
+ row = pool.select_all(
|
|
|
|
|
+ "SELECT MIN(COALESCE(completion_time, sold_time, sell_time)), "
|
|
|
|
|
+ "MAX(COALESCE(completion_time, sold_time, sell_time)) "
|
|
|
|
|
+ "FROM super_vault_product_record "
|
|
|
|
|
+ "WHERE sold_count > 0 AND status NOT IN (0, 1, 2, 3) "
|
|
|
|
|
+ "AND (completion_time IS NOT NULL OR sold_time IS NOT NULL OR sell_time <= NOW())")[0]
|
|
|
|
|
+ lo, hi = row[0], row[1]
|
|
|
|
|
+ tag = f"{_fmt_dt(lo, '%Y%m%d') or 'NA'}-{_fmt_dt(hi, '%Y%m%d') or 'NA'}"
|
|
|
|
|
+ return f"已售累计 成交/开售时间 {_fmt_dt(lo) or '-'} ~ {_fmt_dt(hi) or '-'}", tag
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fetch_platform_summary(pool) -> dict:
|
|
|
|
|
+ """统计平台大盘汇总(窗口内全部已售商品)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ dict: 含 主播数/销售额/成团数/参与人数/均拼单价/人均消费。
|
|
|
|
|
+ """
|
|
|
|
|
+ sql = f"""
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ ROUND(SUM(p.sign_price * p.count), 2) AS amount,
|
|
|
|
|
+ COUNT(DISTINCT p.anchor_username) AS anchors,
|
|
|
|
|
+ COUNT(*) AS grp,
|
|
|
|
|
+ (SELECT COUNT(DISTINCT rr.user_name)
|
|
|
|
|
+ FROM super_vault_report_record rr
|
|
|
|
|
+ JOIN super_vault_product_record pp ON pp.pid = rr.pid
|
|
|
|
|
+ WHERE {win_clause('pp')}
|
|
|
|
|
+ AND rr.user_name IS NOT NULL AND rr.user_name <> '') AS people
|
|
|
|
|
+ FROM super_vault_product_record p
|
|
|
|
|
+ WHERE {win_clause('p')}
|
|
|
|
|
+ """
|
|
|
|
|
+ amount, anchors, groups, people = pool.select_all(sql)[0]
|
|
|
|
|
+ return _pack_summary(amount, groups, people, extra={"主播数": anchors})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fetch_anchor_summaries(pool) -> list[dict]:
|
|
|
|
|
+ """统计各主播汇总(窗口内),每主播一行,按销售额倒序。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ list[dict]: 每元素含 主播名/销售额/成团数/参与人数/均拼单价/人均消费。
|
|
|
|
|
+ """
|
|
|
|
|
+ sql = f"""
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ p.anchor_username AS aname,
|
|
|
|
|
+ ROUND(SUM(p.sign_price * p.count), 2) AS amount,
|
|
|
|
|
+ COUNT(*) AS grp,
|
|
|
|
|
+ (SELECT COUNT(DISTINCT rr.user_name)
|
|
|
|
|
+ FROM super_vault_report_record rr
|
|
|
|
|
+ JOIN super_vault_product_record pp ON pp.pid = rr.pid
|
|
|
|
|
+ WHERE pp.anchor_username = p.anchor_username AND {win_clause('pp')}
|
|
|
|
|
+ AND rr.user_name IS NOT NULL AND rr.user_name <> '') AS people
|
|
|
|
|
+ FROM super_vault_product_record p
|
|
|
|
|
+ WHERE {win_clause('p')}
|
|
|
|
|
+ GROUP BY p.anchor_username
|
|
|
|
|
+ ORDER BY amount DESC
|
|
|
|
|
+ """
|
|
|
|
|
+ rows = pool.select_all(sql) or []
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for aname, amount, groups, people in rows:
|
|
|
|
|
+ d = _pack_summary(amount, groups, people)
|
|
|
|
|
+ d["主播名"] = aname or "(未知主播)"
|
|
|
|
|
+ result.append(d)
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fetch_anchor_details(pool, aname: str) -> list[dict]:
|
|
|
|
|
+ """取单个主播窗口内每个已售拼团(组队)的明细,按总金额倒序。
|
|
|
|
|
+
|
|
|
|
|
+ 按 anchor_username 取数:同名不同 anchor_id 的团合并归入同一主播。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+ aname (str): 主播名 anchor_username。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ list[dict]: 每元素为一条明细(团名/编号/卡种/规格/单价/总份数/进度/总金额/参与人数/开售时间/成交时间/售卖时长)。
|
|
|
|
|
+ """
|
|
|
|
|
+ sql = f"""
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ p.title, p.serial, p.type_name, p.standard_name, p.sign_price, p.sold_count, p.count,
|
|
|
|
|
+ ROUND(p.sign_price * p.count, 2) AS amount,
|
|
|
|
|
+ -- 该团参与人数:拆卡报告 user_name 去重(中卡近似,仅覆盖有报告的团)
|
|
|
|
|
+ (SELECT COUNT(DISTINCT rr.user_name) FROM super_vault_report_record rr
|
|
|
|
|
+ WHERE rr.pid = p.pid AND rr.user_name IS NOT NULL AND rr.user_name <> '') AS buyers,
|
|
|
|
|
+ p.sell_time,
|
|
|
|
|
+ COALESCE(p.completion_time, p.sold_time) AS deal_time,
|
|
|
|
|
+ TIMESTAMPDIFF(SECOND, p.sell_time, COALESCE(p.completion_time, p.sold_time)) AS duration_secs
|
|
|
|
|
+ FROM super_vault_product_record p
|
|
|
|
|
+ WHERE p.anchor_username = %s AND {win_clause('p')}
|
|
|
|
|
+ ORDER BY amount DESC
|
|
|
|
|
+ """
|
|
|
|
|
+ rows = pool.select_all(sql, (aname,)) or []
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for (title, serial, type_name, standard, price, sold, total, amount, buyers,
|
|
|
|
|
+ sell_time, deal_time, duration_secs) in rows:
|
|
|
|
|
+ progress = round(sold / total * 100, 1) if total else None # 售卖进度百分比
|
|
|
|
|
+ result.append({
|
|
|
|
|
+ "团名": title, "编号": serial, "卡种": type_name, "规格": standard,
|
|
|
|
|
+ "单价": price, "总份数": total, "进度": progress, "总金额": amount,
|
|
|
|
|
+ "参与人数": buyers, # 本团拆卡报告 user_name 去重(中卡近似)
|
|
|
|
|
+ # 成交时间 = 完成时间(发货完成)优先,缺则用售罄成交时刻 sold_time;售卖时长=成交-开售
|
|
|
|
|
+ "开售时间": _fmt_dt(sell_time), "成交时间": _fmt_dt(deal_time),
|
|
|
|
|
+ "售卖时长": _fmt_duration(duration_secs),
|
|
|
|
|
+ })
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _pack_summary(amount, groups, people, extra: dict = None) -> dict:
|
|
|
|
|
+ """把销售额/成团数/参与人数打包成统一汇总 dict,并算均拼单价、人均消费。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ amount (Decimal | None): 销售额。
|
|
|
|
|
+ groups (int | None): 成团数。
|
|
|
|
|
+ people (int | None): 参与人数。
|
|
|
|
|
+ extra (dict, optional): 额外要合并的字段(如主播数)。Defaults to None。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ dict: 含 销售额/成团数/参与人数/均拼单价/人均消费(+extra)。
|
|
|
|
|
+ """
|
|
|
|
|
+ amount = float(amount) if amount is not None else 0.0
|
|
|
|
|
+ groups = groups or 0
|
|
|
|
|
+ people = people or 0
|
|
|
|
|
+ avg_group = round(amount / groups, 2) if groups else None # 均拼单价=销售额/成团数
|
|
|
|
|
+ avg_person = round(amount / people, 2) if people else None # 人均消费=销售额/参与人数
|
|
|
|
|
+ d = {"销售额": round(amount, 2), "成团数": groups, "参与人数": people,
|
|
|
|
|
+ "均拼单价": avg_group, "人均消费": avg_person}
|
|
|
|
|
+ if extra:
|
|
|
|
|
+ d.update(extra)
|
|
|
|
|
+ return d
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_dt(dt, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None:
|
|
|
|
|
+ """把 datetime/str 时间格式化为字符串(写入 Excel 前统一转文本,避免日期序列/格式错乱)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ dt (datetime | str | None): 时间值。
|
|
|
|
|
+ fmt (str, optional): 目标格式。Defaults to "%Y-%m-%d %H:%M:%S"。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str | None: 格式化字符串;为空时返回 None。
|
|
|
|
|
+ """
|
|
|
|
|
+ if dt is None or dt == "":
|
|
|
|
|
+ return None
|
|
|
|
|
+ if isinstance(dt, str):
|
|
|
|
|
+ try:
|
|
|
|
|
+ dt = datetime.strptime(dt, "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return dt # 非标准字符串原样返回
|
|
|
|
|
+ return dt.strftime(fmt)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_duration(secs) -> str | None:
|
|
|
|
|
+ """把售卖时长(秒)格式化为可读中文字符串。
|
|
|
|
|
+
|
|
|
|
|
+ 差值口径 = 成交完成时间 completion_time − 开售时间 sell_time,反映该团多快卖完/售罄。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ secs (int | None): 成交与开售之差(秒);None 或负数(时间缺失/异常)视为无效。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str | None: 形如 "2小时13分" / "45分07秒" / "38秒";无效时返回 None(单元格留空)。
|
|
|
|
|
+ """
|
|
|
|
|
+ if secs is None or secs < 0:
|
|
|
|
|
+ return None
|
|
|
|
|
+ secs = int(secs)
|
|
|
|
|
+ h, rem = divmod(secs, 3600)
|
|
|
|
|
+ m, s = divmod(rem, 60)
|
|
|
|
|
+ if h:
|
|
|
|
|
+ return f"{h}小时{m}分"
|
|
|
|
|
+ if m:
|
|
|
|
|
+ return f"{m}分{s:02d}秒"
|
|
|
|
|
+ return f"{s}秒"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _style_row(ws, row_idx: int, ncol: int, font=None, fill=None, border=True):
|
|
|
|
|
+ """给某一行的前 ncol 个单元格套字体/填充/边框。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ws: openpyxl worksheet。
|
|
|
|
|
+ row_idx (int): 行号(1-based)。
|
|
|
|
|
+ ncol (int): 要处理的列数(从第 1 列起)。
|
|
|
|
|
+ font (Font, optional): 字体。Defaults to None。
|
|
|
|
|
+ fill (PatternFill, optional): 填充。Defaults to None。
|
|
|
|
|
+ border (bool, optional): 是否加边框。Defaults to True。
|
|
|
|
|
+ """
|
|
|
|
|
+ for c in range(1, ncol + 1):
|
|
|
|
|
+ cell = ws.cell(row=row_idx, column=c)
|
|
|
|
|
+ if font:
|
|
|
|
|
+ cell.font = font
|
|
|
|
|
+ if fill:
|
|
|
|
|
+ cell.fill = fill
|
|
|
|
|
+ if border:
|
|
|
|
|
+ cell.border = BORDER
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _write_summary_block(ws, r: int, headers: list, data: dict) -> int:
|
|
|
|
|
+ """在第 r 行起竖排写汇总(每指标一行:A列指标名 + B列数值),返回下一空闲行。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ws: openpyxl worksheet。
|
|
|
|
|
+ r (int): 起始行号。
|
|
|
|
|
+ headers (list[str]): 汇总指标名列表。
|
|
|
|
|
+ data (dict): 汇总数据 dict。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 下一个空闲行号。
|
|
|
|
|
+ """
|
|
|
|
|
+ for h in headers:
|
|
|
|
|
+ label = ws.cell(row=r, column=1, value=h) # A 列:指标名(加粗浅蓝)
|
|
|
|
|
+ label.font = FONT_HEADER
|
|
|
|
|
+ label.fill = FILL_HEADER
|
|
|
|
|
+ label.border = BORDER
|
|
|
|
|
+ val = ws.cell(row=r, column=2, value=data.get(h)) # B 列:数值(浅橙、左对齐)
|
|
|
|
|
+ if h in ("销售额", "均拼单价", "人均消费"):
|
|
|
|
|
+ val.number_format = MONEY_FMT
|
|
|
|
|
+ val.fill = FILL_SUBTOTAL
|
|
|
|
|
+ val.alignment = ALIGN_LEFT
|
|
|
|
|
+ val.border = BORDER
|
|
|
|
|
+ r += 1
|
|
|
|
|
+ return r
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _write_section_title(ws, r: int, text: str) -> int:
|
|
|
|
|
+ """在第 r 行写一行分区大标题(蓝底白字,横向铺满整表宽)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ws: openpyxl worksheet。
|
|
|
|
|
+ r (int): 起始行号。
|
|
|
|
|
+ text (str): 标题文字。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 下一个空闲行号。
|
|
|
|
|
+ """
|
|
|
|
|
+ ws.cell(row=r, column=1, value=text)
|
|
|
|
|
+ _style_row(ws, r, SECTION_SPAN, font=FONT_SECTION, fill=FILL_SECTION, border=False)
|
|
|
|
|
+ return r + 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _write_anchor_table(ws, r: int, anchors: list) -> int:
|
|
|
|
|
+ """在第 r 行起横排写各主播汇总表(表头 + 每主播一行),返回下一空闲行。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ws: openpyxl worksheet。
|
|
|
|
|
+ r (int): 起始行号。
|
|
|
|
|
+ anchors (list[dict]): 各主播汇总列表(fetch_anchor_summaries 的返回)。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 下一个空闲行号。
|
|
|
|
|
+ """
|
|
|
|
|
+ headers = ["主播"] + SUMMARY_HEADERS
|
|
|
|
|
+ for i, h in enumerate(headers):
|
|
|
|
|
+ ws.cell(row=r, column=1 + i, value=h)
|
|
|
|
|
+ _style_row(ws, r, len(headers), font=FONT_HEADER, fill=FILL_HEADER)
|
|
|
|
|
+ r += 1
|
|
|
|
|
+ for a in anchors:
|
|
|
|
|
+ vals = [a["主播名"], a["销售额"], a["成团数"], a["参与人数"], a["均拼单价"], a["人均消费"]]
|
|
|
|
|
+ for i, v in enumerate(vals):
|
|
|
|
|
+ cell = ws.cell(row=r, column=1 + i, value=v)
|
|
|
|
|
+ if headers[i] in ("销售额", "均拼单价", "人均消费"):
|
|
|
|
|
+ cell.number_format = MONEY_FMT
|
|
|
|
|
+ cell.alignment = ALIGN_LEFT
|
|
|
|
|
+ _style_row(ws, r, len(headers))
|
|
|
|
|
+ r += 1
|
|
|
|
|
+ return r
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _write_details(ws, r: int, details: list, cols: list) -> int:
|
|
|
|
|
+ """在第 r 行起写一个主播的明细表(表头 + 每条组队明细)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ws: openpyxl worksheet。
|
|
|
|
|
+ r (int): 起始行号。
|
|
|
|
|
+ details (list[dict]): 明细数据(fetch_anchor_details 的返回)。
|
|
|
|
|
+ cols (list[tuple]): 列规格列表,每项 (表头, 取值键, 是否金额格式);取值键为「序号」时按行号自增填充。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ int: 下一个空闲行号。
|
|
|
|
|
+ """
|
|
|
|
|
+ for i, (header, _key, _money) in enumerate(cols):
|
|
|
|
|
+ ws.cell(row=r, column=1 + i, value=header)
|
|
|
|
|
+ _style_row(ws, r, len(cols), font=FONT_HEADER, fill=FILL_HEADER)
|
|
|
|
|
+ r += 1
|
|
|
|
|
+
|
|
|
|
|
+ if not details:
|
|
|
|
|
+ ws.cell(row=r, column=1, value="(该窗口无成交)")
|
|
|
|
|
+ return r + 1
|
|
|
|
|
+
|
|
|
|
|
+ for idx, d in enumerate(details, 1):
|
|
|
|
|
+ for i, (_header, key, is_money) in enumerate(cols):
|
|
|
|
|
+ value = idx if key == "序号" else d.get(key) # 「序号」列用行号,其余按键取值
|
|
|
|
|
+ cell = ws.cell(row=r, column=1 + i, value=value)
|
|
|
|
|
+ if is_money:
|
|
|
|
|
+ cell.number_format = MONEY_FMT
|
|
|
|
|
+ _style_row(ws, r, len(cols))
|
|
|
|
|
+ r += 1
|
|
|
|
|
+ return r
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_report(pool, out: str):
|
|
|
|
|
+ """汇总各段数据并生成单 Sheet 分区 Excel 报告。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+ out (str): 导出的 xlsx 路径。
|
|
|
|
|
+ """
|
|
|
|
|
+ win_label, _ = get_window_label(pool)
|
|
|
|
|
+ platform = fetch_platform_summary(pool)
|
|
|
|
|
+ anchors = fetch_anchor_summaries(pool)
|
|
|
|
|
+
|
|
|
|
|
+ wb = Workbook()
|
|
|
|
|
+ ws = wb.active
|
|
|
|
|
+ ws.title = "已售统计报告"
|
|
|
|
|
+ r = 1 # 显式行游标(避免依赖 ws.max_row 忽略空行的问题)
|
|
|
|
|
+
|
|
|
|
|
+ # 报告大标题
|
|
|
|
|
+ ws.cell(row=r, column=1, value=f"超级仓库 SuperVault · 已售统计报告 ({win_label})")
|
|
|
|
|
+ _style_row(ws, r, SECTION_SPAN, font=FONT_TITLE, border=False)
|
|
|
|
|
+ r += 2 # 空一行
|
|
|
|
|
+
|
|
|
|
|
+ # 一、平台汇总(自营,全站视为一个商家;主播为二级拆分维度)
|
|
|
|
|
+ r = _write_section_title(ws, r, "一、平台汇总(自营·全站累计)")
|
|
|
|
|
+ r = _write_summary_block(ws, r, ["主播数"] + SUMMARY_HEADERS, platform)
|
|
|
|
|
+ r += 1
|
|
|
|
|
+
|
|
|
|
|
+ # 二、各主播汇总
|
|
|
|
|
+ r = _write_section_title(ws, r, f"二、各主播汇总(共 {len(anchors)} 位,按销售额倒序)")
|
|
|
|
|
+ r = _write_anchor_table(ws, r, anchors)
|
|
|
|
|
+ r += 2
|
|
|
|
|
+
|
|
|
|
|
+ # 三、各主播每条组队明细(按主播销售额倒序分块)
|
|
|
|
|
+ r = _write_section_title(ws, r, "三、各主播每条组队明细(按总金额倒序)")
|
|
|
|
|
+ for a in anchors:
|
|
|
|
|
+ details = fetch_anchor_details(pool, a["主播名"])
|
|
|
|
|
+ ws.cell(row=r, column=1,
|
|
|
|
|
+ value=(f" ▼ {a['主播名']} 销售额 {a['销售额']:,.2f} / 成团 {a['成团数']} / "
|
|
|
|
|
+ f"参与 {a['参与人数']}(共 {len(details)} 条明细)"))
|
|
|
|
|
+ _style_row(ws, r, SECTION_SPAN, font=FONT_HEADER, border=False)
|
|
|
|
|
+ r += 1
|
|
|
|
|
+ r = _write_details(ws, r, details, DETAIL_COLS)
|
|
|
|
|
+ r += 2
|
|
|
|
|
+
|
|
|
|
|
+ # 列宽:A-M 兼顾明细表与主播汇总表;E 列取 14 以容纳主播汇总的「均拼单价」大额(否则显示 ######)
|
|
|
|
|
+ widths = [8, 50, 14, 18, 14, 11, 10, 8, 14, 12, 20, 20, 14]
|
|
|
|
|
+ for i, w in enumerate(widths):
|
|
|
|
|
+ ws.column_dimensions[chr(65 + i)].width = w
|
|
|
|
|
+ ws.freeze_panes = "A2" # 冻结标题行
|
|
|
|
|
+
|
|
|
|
|
+ wb.save(out)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def run_once(log) -> str:
|
|
|
|
|
+ """连库生成已售统计报告(可选发送到微信群)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ log: 日志对象。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
|
|
|
|
|
+ """
|
|
|
|
|
+ pool = MySQLConnectionPool(log=log)
|
|
|
|
|
+ if not pool.check_pool_health():
|
|
|
|
|
+ log.error("数据库连接池异常")
|
|
|
|
|
+ return ""
|
|
|
|
|
+ # 文件名带窗口标签,避免覆盖;绝对路径不受运行目录影响
|
|
|
|
|
+ _, tag = get_window_label(pool)
|
|
|
|
|
+ out_dir = os.path.dirname(os.path.abspath(__file__)) # 输出到本脚本所在 stats 目录
|
|
|
|
|
+ out_file = os.path.join(out_dir, f"{OUT_PREFIX}_{tag}.xlsx")
|
|
|
|
|
+ build_report(pool, out_file)
|
|
|
|
|
+ log.info(f"报告已生成 -> {out_file}")
|
|
|
|
|
+
|
|
|
|
|
+ # 发企微群(只发 Excel;失败仅告警,不影响报告产出)
|
|
|
|
|
+ # if SEND_WECHAT:
|
|
|
|
|
+ # try:
|
|
|
|
|
+ # from auto_send_wx_msg import send_wechat_group_file
|
|
|
|
|
+ # send_wechat_group_file(log=log, file_path=out_file)
|
|
|
|
|
+ # except Exception as e:
|
|
|
|
|
+ # log.warning(f"企微发送跳过: {e}")
|
|
|
|
|
+ return out_file
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main():
|
|
|
|
|
+ """命令行一次性生成(手动/调试用)。"""
|
|
|
|
|
+ run_once(logger)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def schedule_task():
|
|
|
|
|
+ """定时入口:每天 09:20 生成已售报告(供日后接入企微/每日推送后使用)。"""
|
|
|
|
|
+ # run_once(logger) # 立即跑一次(调试时取消注释)
|
|
|
|
|
+ schedule.every().day.at("09:20").do(run_once, logger)
|
|
|
|
|
+ while True:
|
|
|
|
|
+ schedule.run_pending()
|
|
|
|
|
+ time.sleep(1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|