|
@@ -0,0 +1,727 @@
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+# Author : Charley
|
|
|
|
|
+# Python : 3.12.10
|
|
|
|
|
+# Date : 2026/08/02
|
|
|
|
|
+"""得卡 DECA 统计报表:从数据库生成一份 Excel(xlsx) + 一张综合图片(PNG)。
|
|
|
|
|
+
|
|
|
|
|
+产出:
|
|
|
|
|
+ - reports/得卡在售每日报告(新增商家/明细)YYYYMMDD_HH时.xlsx 多 Sheet 汇总(文件名带小时,每天 01/09/15/20 四档各留一份、互不覆盖):
|
|
|
|
|
+ Sheet 概览 / 今日新增商家 / 其他商家 / 商品明细 / 在售趋势 / 上架时段分布
|
|
|
|
|
+ 明细字段全量、不截断:商家 / 标题 / 系列 / 规格类型 / 规格详情 / 模式 /
|
|
|
|
|
+ 上架时间 / 预计结束时间 / 单价 / 已售数 / 总数 /
|
|
|
|
|
+ 已售总价 / 新品
|
|
|
|
|
+ 2026/08/14 按监测清单新增两 Sheet:
|
|
|
|
|
+ · 在售趋势 :今日+前3日 在售商家数/在售拼团数/新增商家数/新增拼团数(每日快照差分)
|
|
|
|
|
+ · 上架时段分布 :近 7 日 24h 上架分布(迷你条形图,运营节奏)
|
|
|
|
|
+ 进度分析不做「固定 10/22 点」双节点:报告每天发 4 次(09/15/20/01),每次即一个时间
|
|
|
|
|
+ 节点,商品明细「进度」列已是当次实时进度。
|
|
|
|
|
+ - reports/deca_report_YYYYMMDD_HHMMSS.png 综合图片(视觉版,一天多次生成互不覆盖)
|
|
|
|
|
+
|
|
|
|
|
+生成后可选自动发送到企业微信群机器人(见 SEND_WECHAT,依赖 auto_send_wx_msg.py)。
|
|
|
|
|
+
|
|
|
|
|
+用法:python deca_report.py
|
|
|
|
|
+"""
|
|
|
|
|
+import os
|
|
|
|
|
+import re
|
|
|
|
|
+import sys
|
|
|
|
|
+# 把项目根目录加入 import 路径:企微发送模块 auto_send_wx_msg.py 只在根目录留一份(WEBHOOK_URL 单点维护)
|
|
|
|
|
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
+from datetime import datetime, timedelta
|
|
|
|
|
+import time
|
|
|
|
|
+import matplotlib
|
|
|
|
|
+matplotlib.use("Agg") # 无 GUI,直接保存文件
|
|
|
|
|
+import matplotlib.pyplot as plt
|
|
|
|
|
+from matplotlib import gridspec
|
|
|
|
|
+from loguru import logger
|
|
|
|
|
+from mysql_pool import MySQLConnectionPool
|
|
|
|
|
+from openpyxl import Workbook
|
|
|
|
|
+from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
|
|
|
|
|
+from openpyxl.utils import get_column_letter
|
|
|
|
|
+
|
|
|
|
|
+logger.remove()
|
|
|
|
|
+logger.add("./logs/onsale_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
|
|
|
|
|
+ format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
|
|
|
|
|
+ level="DEBUG", retention="7 day")
|
|
|
|
|
+
|
|
|
|
|
+# 中文字体 + 负号正常显示
|
|
|
|
|
+plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei"]
|
|
|
|
|
+plt.rcParams["axes.unicode_minus"] = False
|
|
|
|
|
+
|
|
|
|
|
+# 配色
|
|
|
|
|
+C_SHOP = "#2563eb" # 主色-蓝
|
|
|
|
|
+C_PROD = "#f59e0b" # 商品-橙
|
|
|
|
|
+C_PROG = "#16a34a" # 进度-绿
|
|
|
|
|
+C_NEW = "#dc2626" # 今日新增-红(强调)
|
|
|
|
|
+C_OTHER = "#0f766e" # 其他商家-青绿(与新增红对比)
|
|
|
|
|
+C_CARD = "#f1f5f9" # KPI 卡片底
|
|
|
|
|
+C_TEXT = "#0f172a" # 主文字
|
|
|
|
|
+C_SUB = "#64748b" # 次文字
|
|
|
|
|
+C_ROW = "#f8fafc" # 表格隔行底色
|
|
|
|
|
+C_NEWROW = "#fef2f2" # 新品行淡红底
|
|
|
|
|
+
|
|
|
|
|
+# 企微发送开关(生成 Excel 后自动发到企业微信群机器人;群由 auto_send_wx_msg.WEBHOOK_URL 决定)
|
|
|
|
|
+SEND_WECHAT = True
|
|
|
|
|
+PRODUCT_LIMIT = 80 # 在售商品明细最多显示的行数(防图过长)
|
|
|
|
|
+SHOP_LIMIT = 100 # 商家表(今日新增/其他)最多显示的行数
|
|
|
|
|
+# ---- 监测清单增强(2026/08/14):在售趋势 / 上架时段 配置 ----
|
|
|
|
|
+TREND_DAYS = 4 # 平台在售趋势展示天数(今日 + 前3日)
|
|
|
|
|
+LISTING_HOUR_DAYS = 7 # 上架时段分布回看天数(平台 24h 上架节奏)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def f(v, default=0.0):
|
|
|
|
|
+ """把可能为 None/Decimal 的值安全转 float。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ v: 原始值(None / Decimal / 数字)。
|
|
|
|
|
+ default (float, optional): 空值时的默认。Defaults to 0.0。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ float: 转换后的浮点数。
|
|
|
|
|
+ """
|
|
|
|
|
+ return default if v is None else float(v)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_time(v) -> str:
|
|
|
|
|
+ """把时间字段渲染成短字符串;空值显示破折号。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ v: 时间原始值(字符串/时间戳/None/空串)。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 截断到 16 字符的时间文本,或 "—"。
|
|
|
|
|
+ """
|
|
|
|
|
+ if v in (None, "", 0):
|
|
|
|
|
+ return "—"
|
|
|
|
|
+ return str(v)[:16]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_money(v) -> str:
|
|
|
|
|
+ """把金额渲染成短字符串:>=1万显示"X.X万",否则整数。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ v: 金额原始值(None/Decimal/数字)。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 格式化后的文本;空值返回 "—"。
|
|
|
|
|
+ """
|
|
|
|
|
+ x = f(v)
|
|
|
|
|
+ if x <= 0:
|
|
|
|
|
+ return "—"
|
|
|
|
|
+ if x >= 10000:
|
|
|
|
|
+ return f"{x / 10000:.1f}万"
|
|
|
|
|
+ return f"{x:.0f}" if x >= 100 else f"{x:.2f}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_progress(sold, total) -> str:
|
|
|
|
|
+ """把已售/总数渲染成售出进度百分比字符串(用于 PNG 图)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ sold: 已售份数(sold_count)。
|
|
|
|
|
+ total: 总份数(card_count)。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 形如 "68.5%" 的进度;总数为 0 或空时返回 "—"。
|
|
|
|
|
+ """
|
|
|
|
|
+ s = f(sold)
|
|
|
|
|
+ t = f(total)
|
|
|
|
|
+ if t <= 0:
|
|
|
|
|
+ return "—"
|
|
|
|
|
+ return f"{s / t * 100:.1f}%"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_spec(spec_name, series_config) -> str:
|
|
|
|
|
+ """把规格组合成一行:`spec_name · series_config`,缺失部分省略。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ spec_name: 顶层规格(原箱/单盒/单包/LOT)。
|
|
|
|
|
+ series_config: 结构化规格(几张/包 几包/盒 几盒/箱 共X箱)。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 组合后的字符串;两者都空返回 "—"。
|
|
|
|
|
+ """
|
|
|
|
|
+ parts = [p for p in (spec_name, series_config) if p]
|
|
|
|
|
+ return " · ".join(parts) if parts else "—"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_series(series_name, title) -> str:
|
|
|
|
|
+ """系列名优先取详情的 series_name(已含 Hobby/Jumbo);缺失时回退到标题正则粗抽。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ series_name: 详情接口 giftInfo.items[0].seriesName。
|
|
|
|
|
+ title: 商品完整标题,仅在 series_name 为空时用作兜底。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 系列文本,截断到 22 字。
|
|
|
|
|
+ """
|
|
|
|
|
+ if series_name:
|
|
|
|
|
+ return series_name[:22]
|
|
|
|
|
+ if not title:
|
|
|
|
|
+ return "—"
|
|
|
|
|
+ # 兜底:粗略抽取 "品牌 系列 版本" 段,避免展示为空
|
|
|
|
|
+ import re
|
|
|
|
|
+ m = re.search(r"(topps|Panini)\s+([\w \-]+?(?:Hobby|Jumbo|Blaster|Retail))", title, flags=re.I)
|
|
|
|
|
+ return (m.group(0) if m else title)[:22]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fetch_data(pool) -> dict:
|
|
|
|
|
+ """从数据库汇总报表所需数据(KPI + 今日新增商家 + 其他商家 + 各商家在售商品明细)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ dict: 各区块所需数据集合。
|
|
|
|
|
+ """
|
|
|
|
|
+ d = {}
|
|
|
|
|
+ # ---- KPI ----
|
|
|
|
|
+ d["total_shops"] = pool.select_one("SELECT COUNT(*) FROM deca_onsale_shop_record")[0]
|
|
|
|
|
+ d["total_products"] = pool.select_one(
|
|
|
|
|
+ "SELECT COUNT(*) FROM deca_onsale_product_record WHERE is_on_sale=1")[0]
|
|
|
|
|
+ d["today_new_shops"] = pool.select_one(
|
|
|
|
|
+ "SELECT COUNT(*) FROM deca_onsale_shop_record WHERE DATE(gmt_create_time)=CURDATE()")[0]
|
|
|
|
|
+ # 今日新增商品口径:按商品「开售时间 publish_at」判定,而非入库时间 gmt_create_time
|
|
|
|
|
+ # 原因:入库时间受采集延迟/故障影响会与真实开售日错位;补抓的老商品也会被误标为今日新增
|
|
|
|
|
+ d["today_new_products"] = pool.select_one(
|
|
|
|
|
+ "SELECT COUNT(*) FROM deca_onsale_product_record "
|
|
|
|
|
+ "WHERE DATE(publish_at)=CURDATE() AND is_on_sale=1")[0]
|
|
|
|
|
+ row = pool.select_one(
|
|
|
|
|
+ "SELECT SUM(sold_count) FROM deca_onsale_product_daily_record WHERE snapshot_date=CURDATE()")
|
|
|
|
|
+ d["today_sold_total"] = int(f(row[0])) if row and row[0] is not None else 0
|
|
|
|
|
+
|
|
|
|
|
+ # ---- 一、今日新增商家(今天首次入库的商家 + 粉丝数 + 在售数 + 今日新增商品数)----
|
|
|
|
|
+ d["new_shop_rows"] = pool.select_all(
|
|
|
|
|
+ "SELECT s.merchant_name, s.fans_count, s.active_groupbuy_count, "
|
|
|
|
|
+ " SUM(CASE WHEN DATE(p.publish_at)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
|
|
|
|
|
+ "FROM deca_onsale_shop_record s "
|
|
|
|
|
+ "LEFT JOIN deca_onsale_product_record p ON p.merchant_user_id = s.merchant_user_id "
|
|
|
|
|
+ "WHERE DATE(s.gmt_create_time)=CURDATE() "
|
|
|
|
|
+ "GROUP BY s.merchant_user_id, s.merchant_name, s.fans_count, s.active_groupbuy_count "
|
|
|
|
|
+ "ORDER BY s.active_groupbuy_count DESC, s.fans_count DESC "
|
|
|
|
|
+ "LIMIT %s", (SHOP_LIMIT,)) or []
|
|
|
|
|
+
|
|
|
|
|
+ # ---- 二、其他商家(非今日新增的存量商家,同格式含粉丝数)----
|
|
|
|
|
+ d["other_shop_rows"] = pool.select_all(
|
|
|
|
|
+ "SELECT s.merchant_name, s.fans_count, s.active_groupbuy_count, "
|
|
|
|
|
+ " SUM(CASE WHEN DATE(p.publish_at)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
|
|
|
|
|
+ "FROM deca_onsale_shop_record s "
|
|
|
|
|
+ "LEFT JOIN deca_onsale_product_record p ON p.merchant_user_id = s.merchant_user_id "
|
|
|
|
|
+ "WHERE DATE(s.gmt_create_time) != CURDATE() "
|
|
|
|
|
+ "GROUP BY s.merchant_user_id, s.merchant_name, s.fans_count, s.active_groupbuy_count "
|
|
|
|
|
+ "ORDER BY s.active_groupbuy_count DESC, s.fans_count DESC, today_new DESC "
|
|
|
|
|
+ "LIMIT %s", (SHOP_LIMIT,)) or []
|
|
|
|
|
+ d["other_shop_total"] = pool.select_one(
|
|
|
|
|
+ "SELECT COUNT(*) FROM deca_onsale_shop_record WHERE DATE(gmt_create_time) != CURDATE()")[0]
|
|
|
|
|
+
|
|
|
|
|
+ # ---- 三、各商家在售商品明细(按商家分组;今日新增商品标记)----
|
|
|
|
|
+ # 新增:series_name(系列+版本Hobby/Jumbo)、spec_name(规格 原箱/单盒/LOT)、series_config(几箱几盒几包)、
|
|
|
|
|
+ # play_type_name(模式 自定义随机/随机球队/...)、unit_price(单价);
|
|
|
|
|
+ # 已售总价:随机团(选队随机/剩余随机) 用 team_total_amount(2026/08/11 新增)——
|
|
|
|
|
+ # 每支球队价格不同,用单一 unit_price×sold_count 会严重失真;具体口径见
|
|
|
|
|
+ # docs/选队随机与剩余随机_总价口径与采集_20260811.md。
|
|
|
|
|
+ # NULL(固定价团 或 数据缺失) 回落到 unit_price×sold_count。
|
|
|
|
|
+ # PNG 版受 PRODUCT_LIMIT 截断(防图过长);Excel 版拉全量(product_rows_all,字段完整无截断)
|
|
|
|
|
+ prod_sql = (
|
|
|
|
|
+ "SELECT merchant_name, title, series_name, spec_name, series_config, play_type_name, "
|
|
|
|
|
+ " unit_price, sold_count, card_count, publish_at, sale_end_at, "
|
|
|
|
|
+ " CASE WHEN DATE(publish_at)=CURDATE() THEN 1 ELSE 0 END AS is_new, " # 新品口径=开售日 publish_at
|
|
|
|
|
+ " COALESCE(team_total_amount, unit_price * sold_count) AS total_amount " # 随机团用 team_total_amount,其它回落
|
|
|
|
|
+ "FROM deca_onsale_product_record "
|
|
|
|
|
+ "WHERE is_on_sale=1 " # 只统计当前在售,排除已下架/售罄陈旧项
|
|
|
|
|
+ "ORDER BY merchant_name, is_new DESC, product_code")
|
|
|
|
|
+ d["product_rows"] = pool.select_all(prod_sql + " LIMIT %s", (PRODUCT_LIMIT,)) or []
|
|
|
|
|
+ d["product_rows_all"] = pool.select_all(prod_sql) or []
|
|
|
|
|
+ d["product_total"] = pool.select_one(
|
|
|
|
|
+ "SELECT COUNT(*) FROM deca_onsale_product_record WHERE is_on_sale=1")[0]
|
|
|
|
|
+
|
|
|
|
|
+ # ---- 四、监测清单增强:在售趋势(差分) / 上架时段(24h) ----
|
|
|
|
|
+ # 注:进度分析不做「固定 10/22 点」双节点——在售报告每天发 4 次(09/15/20/01),每次发送
|
|
|
|
|
+ # 本身即一个时间节点,商品明细的「进度」列就是当次发送时刻的实时进度,无需再造人工节点。
|
|
|
|
|
+ d["trend"] = fetch_onsale_trend(pool, TREND_DAYS)
|
|
|
|
|
+ d["listing_hours"] = fetch_listing_hour_dist(pool, LISTING_HOUR_DAYS)
|
|
|
|
|
+ return d
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _bar(value: int, max_value: int, width: int = 20) -> str:
|
|
|
|
|
+ """把数值渲染成等宽条形字符串(Excel 内迷你直方图)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ value (int): 当前值。
|
|
|
|
|
+ max_value (int): 该组最大值(归一化条长用)。
|
|
|
|
|
+ width (int, optional): 满值时的条长(字符数)。Defaults to 20。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 由 █ 组成的条;max_value<=0 或 value<=0 时返回空串。
|
|
|
|
|
+ """
|
|
|
|
|
+ if max_value <= 0 or value <= 0:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ return "█" * max(1, round(value / max_value * width))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fetch_onsale_trend(pool, days: int) -> list:
|
|
|
|
|
+ """按每日在售快照统计近 N 天的平台在售趋势(商家数/拼团数/新增商家/新增拼团)。
|
|
|
|
|
+
|
|
|
|
|
+ 在售商家数/在售拼团数取当日快照去重计数;新增数为「当日快照相对前一日的差分」
|
|
|
|
|
+ (当日出现、前一日没有的 merchant_user_id / product_code 数),即真正的净新增。
|
|
|
|
|
+ 源表 deca_onsale_product_daily_record(每日一份在售快照)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+ days (int): 展示天数(含今天)。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ list[dict]: 按日期倒序,每项含 日期/在售商家数/在售拼团数/新增商家数/新增拼团数;
|
|
|
|
|
+ 最早一天若缺前一日基线,新增列为 None。
|
|
|
|
|
+ """
|
|
|
|
|
+ # 多取一天做最早展示日的差分基线(WHERE 覆盖到 CURDATE()-days)
|
|
|
|
|
+ rows = pool.select_all(
|
|
|
|
|
+ "SELECT snapshot_date, merchant_user_id, product_code "
|
|
|
|
|
+ "FROM deca_onsale_product_daily_record "
|
|
|
|
|
+ "WHERE snapshot_date >= CURDATE() - INTERVAL %s DAY", (days,)) or []
|
|
|
|
|
+ day_shops, day_prods = {}, {}
|
|
|
|
|
+ for snap_date, mid, code in rows:
|
|
|
|
|
+ day_shops.setdefault(snap_date, set()).add(mid)
|
|
|
|
|
+ day_prods.setdefault(snap_date, set()).add(code)
|
|
|
|
|
+ dates = sorted(day_shops.keys(), reverse=True) # 新 → 旧
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for snap_date in dates[:days]:
|
|
|
|
|
+ prev = snap_date - timedelta(days=1) # 前一日基线
|
|
|
|
|
+ shops, prods = day_shops[snap_date], day_prods[snap_date]
|
|
|
|
|
+ if prev in day_shops:
|
|
|
|
|
+ new_shops = len(shops - day_shops[prev]) # 净新增商家 = 当日有、前日无
|
|
|
|
|
+ new_prods = len(prods - day_prods[prev]) # 净新增拼团 = 当日有、前日无
|
|
|
|
|
+ else:
|
|
|
|
|
+ new_shops = new_prods = None # 无基线,诚实留空
|
|
|
|
|
+ result.append({"日期": str(snap_date), "在售商家数": len(shops), "在售拼团数": len(prods),
|
|
|
|
|
+ "新增商家数": new_shops, "新增拼团数": new_prods})
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fetch_listing_hour_dist(pool, days: int) -> list:
|
|
|
|
|
+ """统计近 N 天上架(publish_at)按小时的 24 桶分布,反映平台 24h 上架节奏。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
+ days (int): 回看天数。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ list[int]: 长度 24,索引=小时(0~23),值=该小时上架的商品数。
|
|
|
|
|
+ """
|
|
|
|
|
+ since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
|
|
|
+ # publish_at 为 'YYYY-MM-DD HH:MM:SS' 字符串,字典序比较等价于时间比较;HOUR() 可直接解析
|
|
|
|
|
+ sql = ("SELECT HOUR(publish_at) h, COUNT(*) c FROM deca_onsale_product_record "
|
|
|
|
|
+ "WHERE publish_at IS NOT NULL AND publish_at <> '' AND publish_at >= %s "
|
|
|
|
|
+ "GROUP BY h")
|
|
|
|
|
+ dist = [0] * 24
|
|
|
|
|
+ for h, c in pool.select_all(sql, (since,)) or []:
|
|
|
|
|
+ if h is not None and 0 <= int(h) < 24:
|
|
|
|
|
+ dist[int(h)] = int(c)
|
|
|
|
|
+ return dist
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _kpi_card(ax, title: str, value, color: str):
|
|
|
|
|
+ """在指定子图里画一个 KPI 卡片(大数字 + 标题)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ax: matplotlib 子图。
|
|
|
|
|
+ title (str): 卡片标题。
|
|
|
|
|
+ value: 展示的数值。
|
|
|
|
|
+ color (str): 数字颜色。
|
|
|
|
|
+ """
|
|
|
|
|
+ ax.axis("off")
|
|
|
|
|
+ ax.add_patch(plt.Rectangle((0.03, 0.08), 0.94, 0.84, transform=ax.transAxes,
|
|
|
|
|
+ facecolor=C_CARD, edgecolor="none", zorder=0))
|
|
|
|
|
+ ax.text(0.5, 0.60, str(value), transform=ax.transAxes, ha="center", va="center",
|
|
|
|
|
+ fontsize=28, fontweight="bold", color=color)
|
|
|
|
|
+ ax.text(0.5, 0.24, title, transform=ax.transAxes, ha="center", va="center",
|
|
|
|
|
+ fontsize=11, color=C_SUB)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _table(ax, title, title_color, col_labels, col_widths, cell_text,
|
|
|
|
|
+ left_cols=(0,), new_flags=None):
|
|
|
|
|
+ """在子图里画一张表(表头着色、隔行底色、指定列左对齐、新品行高亮)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ax: matplotlib 子图。
|
|
|
|
|
+ title (str): 表标题。
|
|
|
|
|
+ title_color (str): 标题与表头底色。
|
|
|
|
|
+ col_labels (list[str]): 列名。
|
|
|
|
|
+ col_widths (list[float]): 各列宽(和为 1)。
|
|
|
|
|
+ cell_text (list[list[str]]): 单元格文本二维数组。
|
|
|
|
|
+ left_cols (tuple[int], optional): 需要左对齐的列下标。Defaults to (0,)。
|
|
|
|
|
+ new_flags (list[bool], optional): 每行是否为"今日新增",True 则整行淡红底。Defaults to None。
|
|
|
|
|
+ """
|
|
|
|
|
+ ax.axis("off")
|
|
|
|
|
+ ax.set_title(title, fontsize=15, fontweight="bold", color=title_color, loc="left", pad=10)
|
|
|
|
|
+ if not cell_text:
|
|
|
|
|
+ ax.text(0.01, 0.5, "暂无数据", transform=ax.transAxes, fontsize=12, color=C_SUB)
|
|
|
|
|
+ return
|
|
|
|
|
+ tbl = ax.table(cellText=cell_text, colLabels=col_labels, colWidths=col_widths,
|
|
|
|
|
+ cellLoc="center", loc="upper center")
|
|
|
|
|
+ tbl.auto_set_font_size(False)
|
|
|
|
|
+ tbl.set_fontsize(10.5)
|
|
|
|
|
+ tbl.scale(1, 1.5)
|
|
|
|
|
+ ncol = len(col_labels)
|
|
|
|
|
+ # 表头
|
|
|
|
|
+ for j in range(ncol):
|
|
|
|
|
+ c = tbl[0, j]
|
|
|
|
|
+ c.set_facecolor(title_color)
|
|
|
|
|
+ c.set_text_props(color="white", fontweight="bold")
|
|
|
|
|
+ c.set_height(c.get_height() * 1.1)
|
|
|
|
|
+ # 数据行
|
|
|
|
|
+ for i in range(1, len(cell_text) + 1):
|
|
|
|
|
+ is_new = bool(new_flags[i - 1]) if new_flags else False
|
|
|
|
|
+ for j in range(ncol):
|
|
|
|
|
+ cell = tbl[i, j]
|
|
|
|
|
+ if is_new:
|
|
|
|
|
+ cell.set_facecolor(C_NEWROW)
|
|
|
|
|
+ elif i % 2 == 0:
|
|
|
|
|
+ cell.set_facecolor(C_ROW)
|
|
|
|
|
+ if j in left_cols:
|
|
|
|
|
+ cell.set_text_props(ha="left")
|
|
|
|
|
+ cell.PAD = 0.03
|
|
|
|
|
+ # 新品列(最后一列)红字加粗
|
|
|
|
|
+ if new_flags and j == ncol - 1 and cell_text[i - 1][j]:
|
|
|
|
|
+ cell.set_text_props(color=C_NEW, fontweight="bold")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _shop_cells(rows: list) -> list:
|
|
|
|
|
+ """把商家汇总行渲染成表格单元格文本(列:商家 / 粉丝数 / 在售商品数 / 今日新增商品)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ rows (list): [(merchant_name, fans_count, active_groupbuy_count, today_new_products)] 列表。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ list[list[str]]: 单元格二维文本。
|
|
|
|
|
+ """
|
|
|
|
|
+ return [[(r[0] or "-")[:18], str(int(f(r[1]))), str(int(f(r[2]))), str(int(f(r[3])))]
|
|
|
|
|
+ for r in rows]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_report(d: dict, out_path: str):
|
|
|
|
|
+ """根据数据渲染报表图并保存(KPI + 今日新增 + 其他商家 + 各商家在售明细)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ d (dict): fetch_data 返回的数据集合。
|
|
|
|
|
+ out_path (str): 输出 PNG 路径。
|
|
|
|
|
+ """
|
|
|
|
|
+ # 一、今日新增商家
|
|
|
|
|
+ new_cells = _shop_cells(d["new_shop_rows"])
|
|
|
|
|
+ # 二、其他商家
|
|
|
|
|
+ other_cells = _shop_cells(d["other_shop_rows"])
|
|
|
|
|
+ # 三、在售商品明细(按商家分组,同商家仅首行显示名)
|
|
|
|
|
+ # 每行 11 列:商家/系列/规格/模式/预计结束时间/单价/已售数/总数/进度/已售总价/新品
|
|
|
|
|
+ # 进度 = 已售数 ÷ 总数(售出百分比)
|
|
|
|
|
+ prod_cells, new_flags, last_name = [], [], None
|
|
|
|
|
+ for r in d["product_rows"]:
|
|
|
|
|
+ (name, title, series_name, spec_name, series_config, play_type_name,
|
|
|
|
|
+ unit_price, sold, card, pub, end, is_new, total_amount) = r
|
|
|
|
|
+ show_name = "" if name == last_name else (name or "-")[:12]
|
|
|
|
|
+ last_name = name
|
|
|
|
|
+ sold_i = int(f(sold))
|
|
|
|
|
+ card_i = int(f(card))
|
|
|
|
|
+ # 已售总价:随机团用 SQL 里 COALESCE 好的 team_total_amount,其它回落 unit_price×sold_count
|
|
|
|
|
+ total = f(total_amount)
|
|
|
|
|
+ prod_cells.append([
|
|
|
|
|
+ show_name,
|
|
|
|
|
+ _fmt_series(series_name, title),
|
|
|
|
|
+ _fmt_spec(spec_name, series_config)[:26],
|
|
|
|
|
+ (play_type_name or "—")[:12],
|
|
|
|
|
+ _fmt_time(end),
|
|
|
|
|
+ f"¥{f(unit_price):.2f}" if f(unit_price) > 0 else "—",
|
|
|
|
|
+ str(sold_i),
|
|
|
|
|
+ str(card_i) if card_i else "—",
|
|
|
|
|
+ _fmt_progress(sold_i, card_i), # 进度 = 已售数 ÷ 总数
|
|
|
|
|
+ _fmt_money(total),
|
|
|
|
|
+ "新" if is_new else "",
|
|
|
|
|
+ ])
|
|
|
|
|
+ new_flags.append(bool(is_new))
|
|
|
|
|
+
|
|
|
|
|
+ n_new = max(len(new_cells), 1)
|
|
|
|
|
+ n_other = max(len(other_cells), 1)
|
|
|
|
|
+ n_prod = max(len(prod_cells), 1)
|
|
|
|
|
+ fig_h = 4.0 + 0.4 * n_new + 0.4 * n_other + 0.4 * n_prod
|
|
|
|
|
+ # 明细表列数从 6 加到 10,图宽加到 14 以保证列不挤
|
|
|
|
|
+ fig = plt.figure(figsize=(14, fig_h), facecolor="white")
|
|
|
|
|
+ gs = gridspec.GridSpec(5, 5, figure=fig,
|
|
|
|
|
+ height_ratios=[0.8, 0.9,
|
|
|
|
|
+ 0.4 * n_new + 0.5,
|
|
|
|
|
+ 0.4 * n_other + 0.5,
|
|
|
|
|
+ 0.4 * n_prod + 0.5],
|
|
|
|
|
+ hspace=0.32, wspace=0.35)
|
|
|
|
|
+
|
|
|
|
|
+ # 标题
|
|
|
|
|
+ ax_title = fig.add_subplot(gs[0, :])
|
|
|
|
|
+ ax_title.axis("off")
|
|
|
|
|
+ ax_title.text(0.5, 0.75, "得卡 DECA 商家在售采集统计", ha="center", va="center",
|
|
|
|
|
+ fontsize=22, fontweight="bold", color=C_TEXT)
|
|
|
|
|
+ ax_title.text(0.98, 0.18, f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
|
|
|
+ ha="right", va="center", fontsize=11, color=C_SUB)
|
|
|
|
|
+
|
|
|
|
|
+ # KPI 行(5 卡片,汇总保留)
|
|
|
|
|
+ kpis = [
|
|
|
|
|
+ ("商家总数", d["total_shops"], C_SHOP),
|
|
|
|
|
+ ("在售商品总数", d["total_products"], C_PROD),
|
|
|
|
|
+ ("今日新增商家", d["today_new_shops"], C_NEW),
|
|
|
|
|
+ ("今日新增商品", d["today_new_products"], C_NEW),
|
|
|
|
|
+ ("今日累计已售(份)", d["today_sold_total"], C_PROG),
|
|
|
|
|
+ ]
|
|
|
|
|
+ for i, (t, v, c) in enumerate(kpis):
|
|
|
|
|
+ _kpi_card(fig.add_subplot(gs[1, i]), t, v, c)
|
|
|
|
|
+
|
|
|
|
|
+ # 一、今日新增商家
|
|
|
|
|
+ _table(fig.add_subplot(gs[2, :]),
|
|
|
|
|
+ f"一、今日新增商家({d['today_new_shops']} 家)", C_NEW,
|
|
|
|
|
+ ["商家", "粉丝数", "在售商品数", "今日新增商品"], [0.40, 0.20, 0.20, 0.20],
|
|
|
|
|
+ new_cells, left_cols=(0,))
|
|
|
|
|
+
|
|
|
|
|
+ # 二、其他商家(存量,非今日新增)
|
|
|
|
|
+ o_total, o_shown = d["other_shop_total"], len(other_cells)
|
|
|
|
|
+ o_suffix = f"(共 {o_total} 家,显示前 {o_shown})" if o_total > o_shown else f"(共 {o_total} 家)"
|
|
|
|
|
+ _table(fig.add_subplot(gs[3, :]),
|
|
|
|
|
+ f"二、其他商家{o_suffix}", C_OTHER,
|
|
|
|
|
+ ["商家", "粉丝数", "在售商品数", "今日新增商品"], [0.40, 0.20, 0.20, 0.20],
|
|
|
|
|
+ other_cells, left_cols=(0,))
|
|
|
|
|
+
|
|
|
|
|
+ # 三、各商家在售商品明细(按商家分组,今日新上架标🆕)
|
|
|
|
|
+ # 列:商家 / 系列(含Hobby/Jumbo) / 规格(原箱·几箱几盒几包) / 模式(playTypeName) /
|
|
|
|
|
+ # 预计结束时间(sale_end_at) / 单价 / 已售数(sold_count) / 总数(card_count) /
|
|
|
|
|
+ # 进度(已售数÷总数) / 已售总价(单价×已售数) / 新品
|
|
|
|
|
+ p_total, p_shown = d["product_total"], len(prod_cells)
|
|
|
|
|
+ p_suffix = f"(在售商品共 {p_total},显示前 {p_shown})" if p_total > p_shown else f"(在售商品 {p_total})"
|
|
|
|
|
+ _table(fig.add_subplot(gs[4, :]),
|
|
|
|
|
+ f"三、各商家在售商品明细{p_suffix}", C_SHOP,
|
|
|
|
|
+ ["商家", "系列", "规格", "模式", "预计结束时间", "单价", "已售数", "总数", "进度", "已售总价", "新"],
|
|
|
|
|
+ [0.09, 0.17, 0.15, 0.09, 0.11, 0.07, 0.07, 0.06, 0.06, 0.09, 0.04],
|
|
|
|
|
+ prod_cells, left_cols=(0, 1, 2, 3), new_flags=new_flags)
|
|
|
|
|
+
|
|
|
|
|
+ fig.savefig(out_path, dpi=150, bbox_inches="tight", facecolor="white")
|
|
|
|
|
+ plt.close(fig)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ==================== Excel 产出 ====================
|
|
|
|
|
+# 配色与已售报告(stats/daily_report.py)统一:浅蓝底 + 深蓝表头字、淡蓝灰斑马纹、浅灰细边框。
|
|
|
|
|
+# 2026/08/14 起各 Sheet 表头不再用蓝/红/青绿区分底色,统一改为已售同款(主公要求两报告风格一致)。
|
|
|
|
|
+XL_HEADER_FILL = PatternFill("solid", fgColor="D9E1F2") # 表头:浅蓝底(同已售 FILL_HEADER)
|
|
|
|
|
+XL_HEADER_FONT = Font(name="Microsoft YaHei", bold=True, color="1F3864") # 表头:深蓝字(同已售 FONT_HEADER)
|
|
|
|
|
+XL_CELL_FONT = Font(name="Microsoft YaHei", size=10) # 正文
|
|
|
|
|
+XL_NEWROW_FILL = PatternFill("solid", fgColor="FEF2F2") # 新品行淡红底(功能高亮,保留)
|
|
|
|
|
+XL_ZEBRA_FILL = PatternFill("solid", fgColor="F5F8FC") # 隔行淡蓝灰(同已售 FILL_ZEBRA)
|
|
|
|
|
+XL_THIN_BORDER = Border(left=Side(style="thin", color="D6DCE5"),
|
|
|
|
|
+ right=Side(style="thin", color="D6DCE5"),
|
|
|
|
|
+ top=Side(style="thin", color="D6DCE5"),
|
|
|
|
|
+ bottom=Side(style="thin", color="D6DCE5"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _write_sheet(ws, headers, rows, new_flags=None,
|
|
|
|
|
+ num_cols=None, money_cols=None, pct_cols=None):
|
|
|
|
|
+ """把一份表格数据(表头+多行)写入 Sheet,套统一样式(与已售报告一致)。
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ws: openpyxl 的 worksheet 对象。
|
|
|
|
|
+ headers (list[str]): 表头列名。
|
|
|
|
|
+ rows (list[list]): 数据行,每行长度应与 headers 一致。
|
|
|
|
|
+ new_flags (list[bool], optional): 每行是否为"今日新增",True 则整行淡红底。Defaults to None。
|
|
|
|
|
+ num_cols (set[int], optional): 需要按整数右对齐显示的列下标(0-based)。Defaults to None。
|
|
|
|
|
+ money_cols (set[int], optional): 需要按金额格式(¥#,##0.00) 显示的列下标(0-based)。Defaults to None。
|
|
|
|
|
+ pct_cols (set[int], optional): 需要按百分比格式(0.0%) 显示的列下标(0-based),值存占比小数如 0.685。Defaults to None。
|
|
|
|
|
+ """
|
|
|
|
|
+ num_cols = num_cols or set()
|
|
|
|
|
+ money_cols = money_cols or set()
|
|
|
|
|
+ pct_cols = pct_cols or set()
|
|
|
|
|
+
|
|
|
|
|
+ # 1) 写表头(浅蓝底 + 深蓝字,居中换行;与已售报告统一)
|
|
|
|
|
+ ws.append(headers)
|
|
|
|
|
+ for c in ws[1]:
|
|
|
|
|
+ c.font = XL_HEADER_FONT
|
|
|
|
|
+ c.fill = XL_HEADER_FILL
|
|
|
|
|
+ c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
|
|
|
+ c.border = XL_THIN_BORDER
|
|
|
|
|
+ ws.row_dimensions[1].height = 26
|
|
|
|
|
+
|
|
|
|
|
+ # 2) 写数据 + 隔行/新品行样式 + 数字/金额格式
|
|
|
|
|
+ for i, row in enumerate(rows, start=2):
|
|
|
|
|
+ ws.append(row)
|
|
|
|
|
+ is_new = bool(new_flags[i - 2]) if new_flags else False
|
|
|
|
|
+ fill = XL_NEWROW_FILL if is_new else (XL_ZEBRA_FILL if i % 2 == 0 else None)
|
|
|
|
|
+ for j, cell in enumerate(ws[i]):
|
|
|
|
|
+ cell.font = XL_CELL_FONT
|
|
|
|
|
+ cell.border = XL_THIN_BORDER
|
|
|
|
|
+ if fill:
|
|
|
|
|
+ cell.fill = fill
|
|
|
|
|
+ if j in money_cols:
|
|
|
|
|
+ cell.number_format = "¥#,##0.00"
|
|
|
|
|
+ cell.alignment = Alignment(horizontal="right", vertical="center")
|
|
|
|
|
+ elif j in pct_cols:
|
|
|
|
|
+ cell.number_format = "0.0%" # 存占比小数(0.685),显示为 68.5%
|
|
|
|
|
+ cell.alignment = Alignment(horizontal="right", vertical="center")
|
|
|
|
|
+ elif j in num_cols:
|
|
|
|
|
+ cell.number_format = "#,##0"
|
|
|
|
|
+ cell.alignment = Alignment(horizontal="right", vertical="center")
|
|
|
|
|
+ else:
|
|
|
|
|
+ cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=False)
|
|
|
|
|
+
|
|
|
|
|
+ # 3) 列宽:按内容估算,中文/表头都算宽度;上下限 [8, 60]
|
|
|
|
|
+ for j, header in enumerate(headers, start=1):
|
|
|
|
|
+ max_len = len(str(header)) * 2 # 表头基准(中文占 2)
|
|
|
|
|
+ for r in ws.iter_rows(min_row=2, min_col=j, max_col=j):
|
|
|
|
|
+ v = r[0].value
|
|
|
|
|
+ if v is None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ s = str(v)
|
|
|
|
|
+ # 中文按 2 宽度,其他按 1
|
|
|
|
|
+ w = sum(2 if ord(ch) > 127 else 1 for ch in s)
|
|
|
|
|
+ if w > max_len:
|
|
|
|
|
+ max_len = w
|
|
|
|
|
+ ws.column_dimensions[get_column_letter(j)].width = max(8, min(60, max_len + 2))
|
|
|
|
|
+
|
|
|
|
|
+ # 4) 冻结表头
|
|
|
|
|
+ ws.freeze_panes = "A2"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_excel(d: dict, out_path: str):
|
|
|
|
|
+ """把报表所有数据集合到一个 xlsx 文件(多 Sheet),字段完整不截断。
|
|
|
|
|
+
|
|
|
|
|
+ Sheet 结构:
|
|
|
|
|
+ 1. 概览 :KPI 5 项 + 生成时间
|
|
|
|
|
+ 2. 今日新增商家 :今天入库的商家(商家/粉丝数/在售商品数/今日新增商品)
|
|
|
|
|
+ 3. 其他商家 :存量商家(同上格式)
|
|
|
|
|
+ 4. 商品明细 :全量在售商品;14 列全字段(含标题、上架时间、规格详情、模式、进度、总价等)
|
|
|
|
|
+ 5. 在售趋势 :今日+前3日 在售商家数/在售拼团数/新增商家数/新增拼团数(每日快照差分)
|
|
|
|
|
+ 6. 上架时段分布 :近 7 日 24h 上架分布(含迷你条形图)
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ d (dict): fetch_data 返回的数据集合(含 product_rows_all 全量明细、trend/listing_hours
|
|
|
|
|
+ 等增强区块数据)。
|
|
|
|
|
+ out_path (str): 输出 xlsx 文件路径。
|
|
|
|
|
+ """
|
|
|
|
|
+ wb = Workbook()
|
|
|
|
|
+
|
|
|
|
|
+ # Sheet 1:概览
|
|
|
|
|
+ ws1 = wb.active
|
|
|
|
|
+ ws1.title = "概览"
|
|
|
|
|
+ overview_rows = [
|
|
|
|
|
+ ["商家总数", int(f(d["total_shops"]))],
|
|
|
|
|
+ ["在售商品总数", int(f(d["total_products"]))],
|
|
|
|
|
+ ["今日新增商家", int(f(d["today_new_shops"]))],
|
|
|
|
|
+ ["今日新增商品", int(f(d["today_new_products"]))],
|
|
|
|
|
+ ["今日累计已售(份)", int(f(d["today_sold_total"]))],
|
|
|
|
|
+ ["生成时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")],
|
|
|
|
|
+ ]
|
|
|
|
|
+ _write_sheet(ws1, ["指标", "数值"], overview_rows, num_cols={1})
|
|
|
|
|
+
|
|
|
|
|
+ # Sheet 2:今日新增商家
|
|
|
|
|
+ ws2 = wb.create_sheet("今日新增商家")
|
|
|
|
|
+ new_rows = [[r[0] or "-", int(f(r[1])), int(f(r[2])), int(f(r[3]))] for r in d["new_shop_rows"]]
|
|
|
|
|
+ _write_sheet(ws2, ["商家", "粉丝数", "在售商品数", "今日新增商品"], new_rows,
|
|
|
|
|
+ num_cols={1, 2, 3})
|
|
|
|
|
+
|
|
|
|
|
+ # Sheet 3:其他商家
|
|
|
|
|
+ ws3 = wb.create_sheet("其他商家")
|
|
|
|
|
+ other_rows = [[r[0] or "-", int(f(r[1])), int(f(r[2])), int(f(r[3]))] for r in d["other_shop_rows"]]
|
|
|
|
|
+ _write_sheet(ws3, ["商家", "粉丝数", "在售商品数", "今日新增商品"], other_rows,
|
|
|
|
|
+ num_cols={1, 2, 3})
|
|
|
|
|
+
|
|
|
|
|
+ # Sheet 4:商品明细(全量、字段不截断)
|
|
|
|
|
+ ws4 = wb.create_sheet("商品明细")
|
|
|
|
|
+ prod_headers = ["商家", "标题", "系列", "规格类型", "规格详情", "模式",
|
|
|
|
|
+ "上架时间", "预计结束时间", "单价", "已售数", "总数", "进度", "已售总价", "新品"]
|
|
|
|
|
+ prod_rows, new_flags = [], []
|
|
|
|
|
+ for r in d["product_rows_all"]:
|
|
|
|
|
+ (name, title, series_name, spec_name, series_config, play_type_name,
|
|
|
|
|
+ unit_price, sold, card, pub, end, is_new, total_amount) = r
|
|
|
|
|
+ sold_i = int(f(sold))
|
|
|
|
|
+ card_i = int(f(card))
|
|
|
|
|
+ unit = f(unit_price)
|
|
|
|
|
+ # 已售总价:随机团用 SQL 里 COALESCE 好的 team_total_amount,其它回落 unit_price×sold_count
|
|
|
|
|
+ total = f(total_amount)
|
|
|
|
|
+ progress = (sold_i / card_i) if card_i else None # 进度 = 已售数 ÷ 总数(占比,Excel 用 0.0% 显示)
|
|
|
|
|
+ prod_rows.append([
|
|
|
|
|
+ name or "-", title or "-", series_name or "-",
|
|
|
|
|
+ spec_name or "-", series_config or "-", play_type_name or "-",
|
|
|
|
|
+ _fmt_time(pub), _fmt_time(end),
|
|
|
|
|
+ unit if unit > 0 else None,
|
|
|
|
|
+ sold_i, card_i,
|
|
|
|
|
+ progress,
|
|
|
|
|
+ total if total > 0 else None,
|
|
|
|
|
+ "新" if is_new else "",
|
|
|
|
|
+ ])
|
|
|
|
|
+ new_flags.append(bool(is_new))
|
|
|
|
|
+ _write_sheet(ws4, prod_headers, prod_rows,
|
|
|
|
|
+ new_flags=new_flags,
|
|
|
|
|
+ num_cols={9, 10}, # 已售数、总数
|
|
|
|
|
+ pct_cols={11}, # 进度(已售数÷总数)
|
|
|
|
|
+ money_cols={8, 12}) # 单价、已售总价
|
|
|
|
|
+
|
|
|
|
|
+ # Sheet 5:平台在售趋势(今日+前3日,按每日快照差分)—— 监测清单「平台层面」
|
|
|
|
|
+ ws5 = wb.create_sheet("在售趋势")
|
|
|
|
|
+ trend_rows = [[t["日期"], t["在售商家数"], t["在售拼团数"], t["新增商家数"], t["新增拼团数"]]
|
|
|
|
|
+ for t in d["trend"]]
|
|
|
|
|
+ _write_sheet(ws5, ["日期", "在售商家数", "在售拼团数", "新增商家数(差分)", "新增拼团数(差分)"],
|
|
|
|
|
+ trend_rows, num_cols={1, 2, 3, 4})
|
|
|
|
|
+
|
|
|
|
|
+ # Sheet 6:上架时段分布(近 N 日 24h 累计)—— 监测清单「运营节奏·上架时段热图」
|
|
|
|
|
+ ws6 = wb.create_sheet("上架时段分布")
|
|
|
|
|
+ lhours = d["listing_hours"]
|
|
|
|
|
+ lmax = max(lhours) if lhours else 0
|
|
|
|
|
+ lrows = [[f"{h:02d}时", lhours[h], _bar(lhours[h], lmax)] for h in range(24)]
|
|
|
|
|
+ _write_sheet(ws6, ["时段", "上架数", f"分布(近{LISTING_HOUR_DAYS}日累计)"], lrows,
|
|
|
|
|
+ num_cols={1})
|
|
|
|
|
+
|
|
|
|
|
+ wb.save(out_path)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main() -> str:
|
|
|
|
|
+ """连库取数、生成 Excel 报表 + PNG 综合图,并按开关自动发送微信。
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ str: 生成的 xlsx 路径;失败返回空串。
|
|
|
|
|
+ """
|
|
|
|
|
+ log = logger
|
|
|
|
|
+ pool = MySQLConnectionPool(log=log)
|
|
|
|
|
+ if not pool.check_pool_health():
|
|
|
|
|
+ log.error("数据库连接池异常")
|
|
|
|
|
+ return ""
|
|
|
|
|
+ data = fetch_data(pool)
|
|
|
|
|
+ os.makedirs("reports", exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+ # 1) Excel 主产出:一份多 Sheet 汇总
|
|
|
|
|
+ # 文件名加"小时"以区分每天 01/09/15/20 四个时段,四份各自留档、互不覆盖
|
|
|
|
|
+ # 注:文件名括号内的分隔符用全角斜杠 U+FF0F,因为 Windows 不允许半角 `/` 出现在文件名里
|
|
|
|
|
+ xlsx_out = os.path.join(
|
|
|
|
|
+ "reports", f"得卡在售每日报告(新增商家/明细){datetime.now().strftime('%Y%m%d_%H时')}.xlsx")
|
|
|
|
|
+ build_excel(data, xlsx_out)
|
|
|
|
|
+ log.info(f"Excel 报表已生成: {xlsx_out}")
|
|
|
|
|
+
|
|
|
|
|
+ # 2) PNG 综合图(视觉版,一天多次生成互不覆盖,便于历史留档)
|
|
|
|
|
+ png_out = os.path.join("reports", f"deca_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png")
|
|
|
|
|
+ build_report(data, png_out)
|
|
|
|
|
+ log.info(f"PNG 报表已生成: {png_out}")
|
|
|
|
|
+
|
|
|
|
|
+ out = xlsx_out
|
|
|
|
|
+
|
|
|
|
|
+ # 自动发企微(失败仅告警,不影响报表产出):每次生成后把 Excel 明细发到企业微信群机器人(只发表格,不发图)
|
|
|
|
|
+ if SEND_WECHAT:
|
|
|
|
|
+ try:
|
|
|
|
|
+ from auto_send_wx_msg import send_wechat_group_file
|
|
|
|
|
+ send_wechat_group_file(log=log, file_path=out) # out=xlsx,只发 Excel
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log.warning(f"企微发送跳过: {e}")
|
|
|
|
|
+ return out
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ # logger.remove()
|
|
|
|
|
+ # logger.add(sys.stderr, level="INFO")
|
|
|
|
|
+ # 用法:
|
|
|
|
|
+ # python deca_on_sale_report.py → 立即查库生成并发送一次(保留原行为)
|
|
|
|
|
+ # python deca_on_sale_report.py loop → 定时常驻:每天 09:00/15:00/20:00/01:00 各查库生成并发微信一次
|
|
|
|
|
+ if len(sys.argv) > 1 and sys.argv[1] == "loop":
|
|
|
|
|
+ import schedule
|
|
|
|
|
+ for _hhmm in ("09:00", "15:00", "20:00", "01:00"):
|
|
|
|
|
+ schedule.every().day.at(_hhmm).do(main)
|
|
|
|
|
+ logger.info("报告定时常驻启动:每天 09:00/15:00/20:00/01:00 查库生成并发微信(数据由 buy_record_spider 每分钟落库)")
|
|
|
|
|
+ while True:
|
|
|
|
|
+ schedule.run_pending()
|
|
|
|
|
+ time.sleep(1)
|
|
|
|
|
+ else:
|
|
|
|
|
+ print("报表已生成:", main())
|