|
|
@@ -10,18 +10,42 @@
|
|
|
有 sold_time 即代表该团已售罄成交,故不再叠加 status/sold_count 过滤。
|
|
|
- 成交时间/售卖时长一律以 sold_time 为准(累计报告用的是 completion_time→sold_time 兜底锚点)。
|
|
|
|
|
|
-报告结构(自上而下单 Sheet 分区,与累计报告一致):
|
|
|
+人数口径(本每日报告与累计报告的差异,仅本文件生效,base 累计报告不受影响):
|
|
|
+ - 中卡人数:原「参与人数」更名。来源 super_vault_report_record 拆卡报告 user_name 去重,仅是
|
|
|
+ 「抽中卡片的人」的近似,且待发货团常尚无报告故偏低,故更名为「中卡人数」以免误解。
|
|
|
+ - 真实购买人数:新增。来源 super_vault_order_record(super_vault_order_spider.py 抓的真实买家订单),
|
|
|
+ 非匿名按 uid/user_name 去重 + 匿名每笔计 1(上界近似)。更贴近真实买家数。
|
|
|
+ 依赖:需 super_vault_order_spider.py 先抓过窗口内商品(建议每日排在本报告之前跑),
|
|
|
+ 否则未抓订单的团该值为 0。
|
|
|
+ - 人均消费:本每日报告改按「销售额 / 真实购买人数」(比 base 累计报告的中卡人数口径更准);
|
|
|
+ 未抓订单的团 real=0 时留空。
|
|
|
+
|
|
|
+报告结构(共两个 Sheet):
|
|
|
+ Sheet1「已售每日报告」(自上而下单 Sheet 分区,与累计报告一致):
|
|
|
一、平台汇总(当日成交累计)
|
|
|
二、各主播汇总(每主播一行,按销售额倒序)
|
|
|
三、各主播每条组队明细(按主播销售额倒序分块,块内按总金额倒序)
|
|
|
+ Sheet2「用户明细」(2026/10/09 新增,参考得卡报告「用户排行榜」):
|
|
|
+ 真实买家按参与金额倒序排行(排名/用户昵称/user_id/参与车数/参与金额/车均消费)。
|
|
|
+ 身份口径同「真实购买人数」(uid 优先、默认头像按 user_name 去重);匿名无稳定身份不进排行、单列汇总。
|
|
|
+ 参与金额按团实收 total_price 分摊到份(金额合计 ≈ 平台销售额),依赖 super_vault_order_spider.py 先抓订单。
|
|
|
|
|
|
从项目根目录运行:python stats/super_vault_nova_daily_report.py(cwd=根目录,读根目录 application.yml)
|
|
|
"""
|
|
|
import os
|
|
|
+import re
|
|
|
import sys
|
|
|
import time
|
|
|
-# 把项目根目录加入 import 路径(读根目录 application.yml、复用根目录公共模块)
|
|
|
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
+import glob
|
|
|
+from datetime import datetime, timedelta
|
|
|
+# 项目根目录(本文件在 stats/ 下,上一级即根目录):公共模块 mysql_pool/YamlLoader、
|
|
|
+# 配置 application.yml、日志目录 logs 统一只在根目录留一份,stats 内不再放副本
|
|
|
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
+sys.path.insert(0, ROOT_DIR)
|
|
|
+# 必须在 import mysql_pool 之前切到根目录:mysql_pool 在「导入期」就按 cwd 相对路径读
|
|
|
+# application.yml(读不到会直接 UnboundLocalError),而配置只在根目录留一份。
|
|
|
+# 入口脚本已切过,这里是幂等的;单独跑本文件时则靠这行兜住任意启动目录。
|
|
|
+os.chdir(ROOT_DIR)
|
|
|
|
|
|
import schedule
|
|
|
from loguru import logger
|
|
|
@@ -31,21 +55,35 @@ from mysql_pool import MySQLConnectionPool
|
|
|
# 复用累计报告脚本的渲染组件与样式,不重复造轮子;口径相关的 fetch_* 在本文件按 sold_time 重写
|
|
|
import super_vault_his_report as base
|
|
|
from super_vault_his_report import (
|
|
|
- DETAIL_COLS, SUMMARY_HEADERS, SECTION_SPAN, FONT_TITLE,
|
|
|
+ DETAIL_COLS, SUMMARY_HEADERS, SECTION_SPAN, FONT_TITLE, REAL_BUYERS_EXPR,
|
|
|
_fmt_dt, _fmt_duration, _pack_summary,
|
|
|
_style_row, _write_summary_block, _write_section_title,
|
|
|
_write_anchor_table, _write_details,
|
|
|
)
|
|
|
|
|
|
-# 日志:按天切分,保留 7 天(覆盖 base 模块 import 时设置的 sink)
|
|
|
-logger.remove()
|
|
|
-logger.add("./logs/nova_daily_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")
|
|
|
+# 日志统一写根目录 logs/{日期}.log,由入口脚本配置(见文件末尾 __main__)。
|
|
|
+# 模块级绝不配置 sink:被 start_cxx_spider 等入口 import 时若在此 logger.remove()/add(),
|
|
|
+# 会摘掉入口已装好的 sink,并凭空创建一个随即被摘、永不写入的 0 字节日志文件。
|
|
|
+LOG_PATH = os.path.join(ROOT_DIR, "logs", "{time:YYYYMMDD}.log")
|
|
|
|
|
|
OUT_PREFIX = "超新星已售每日报告" # 输出文件名前缀,后缀加统计日期
|
|
|
|
|
|
+# 历史报告保留天数:每日产出一份 xlsx,长期无人值守会堆满磁盘,只留最近 N 天(按文件名里的统计日期算)
|
|
|
+KEEP_DAYS = 30
|
|
|
+# 匹配本脚本产出的文件名:{前缀}_YYYYMMDD.xlsx。清理只认这种严格格式,手工改名/其他报表一律不碰
|
|
|
+OUT_FILE_PATTERN = re.compile(rf"^{re.escape(OUT_PREFIX)}_(\d{{8}})\.xlsx$")
|
|
|
+
|
|
|
# 企微发送开关:默认关闭;接入企微机器人后置 True 即启用(发送模块 auto_send_wx_msg.py 在同目录)
|
|
|
-SEND_WECHAT = False
|
|
|
+SEND_WECHAT = True
|
|
|
+
|
|
|
+# 人数口径(中卡人数/真实购买人数)与列结构、渲染均复用 base(super_vault_his_report),
|
|
|
+# 本文件仅按 sold_time「昨天」窗口重写 fetch_*;REAL_BUYERS_EXPR/DETAIL_COLS 等直接 import。
|
|
|
+
|
|
|
+
|
|
|
+def amount_expr(alias: str) -> str:
|
|
|
+ """生成销售额 SQL 表达式:优先用实收总价 total_price,缺失时才回退满仓价。"""
|
|
|
+ return (f"CASE WHEN {alias}.total_price IS NOT NULL "
|
|
|
+ f"THEN {alias}.total_price ELSE {alias}.sign_price * {alias}.count END")
|
|
|
|
|
|
|
|
|
def win_clause(alias: str) -> str:
|
|
|
@@ -85,23 +123,27 @@ def fetch_platform_summary(pool) -> dict:
|
|
|
pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
|
Returns:
|
|
|
- dict: 含 主播数/销售额/成团数/参与人数/均拼单价/人均消费。
|
|
|
+ dict: 含 主播数/销售额/成团数/中卡人数/真实购买人数/均拼单价/人均消费。
|
|
|
"""
|
|
|
sql = f"""
|
|
|
SELECT
|
|
|
- ROUND(SUM(p.sign_price * p.count), 2) AS amount,
|
|
|
+ ROUND(SUM({amount_expr('p')}), 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
|
|
|
+ AND rr.user_name IS NOT NULL AND rr.user_name <> '') AS people,
|
|
|
+ (SELECT {REAL_BUYERS_EXPR}
|
|
|
+ FROM super_vault_order_record o
|
|
|
+ JOIN super_vault_product_record pp2 ON pp2.pid = o.pid
|
|
|
+ WHERE {win_clause('pp2')}) AS real_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})
|
|
|
+ amount, anchors, groups, people, real_people = pool.select_all(sql)[0]
|
|
|
+ return _pack_summary(amount, groups, people, real_people, extra={"主播数": anchors})
|
|
|
|
|
|
|
|
|
def fetch_anchor_summaries(pool) -> list[dict]:
|
|
|
@@ -111,18 +153,22 @@ def fetch_anchor_summaries(pool) -> list[dict]:
|
|
|
pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
|
|
|
Returns:
|
|
|
- list[dict]: 每元素含 主播名/销售额/成团数/参与人数/均拼单价/人均消费。
|
|
|
+ list[dict]: 每元素含 主播名/销售额/成团数/中卡人数/真实购买人数/均拼单价/人均消费。
|
|
|
"""
|
|
|
sql = f"""
|
|
|
SELECT
|
|
|
p.anchor_username AS aname,
|
|
|
- ROUND(SUM(p.sign_price * p.count), 2) AS amount,
|
|
|
+ ROUND(SUM({amount_expr('p')}), 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
|
|
|
+ AND rr.user_name IS NOT NULL AND rr.user_name <> '') AS people,
|
|
|
+ (SELECT {REAL_BUYERS_EXPR}
|
|
|
+ FROM super_vault_order_record o
|
|
|
+ JOIN super_vault_product_record pp2 ON pp2.pid = o.pid
|
|
|
+ WHERE pp2.anchor_username = p.anchor_username AND {win_clause('pp2')}) AS real_people
|
|
|
FROM super_vault_product_record p
|
|
|
WHERE {win_clause('p')}
|
|
|
GROUP BY p.anchor_username
|
|
|
@@ -130,8 +176,8 @@ def fetch_anchor_summaries(pool) -> list[dict]:
|
|
|
"""
|
|
|
rows = pool.select_all(sql) or []
|
|
|
result = []
|
|
|
- for aname, amount, groups, people in rows:
|
|
|
- d = _pack_summary(amount, groups, people)
|
|
|
+ for aname, amount, groups, people, real_people in rows:
|
|
|
+ d = _pack_summary(amount, groups, people, real_people)
|
|
|
d["主播名"] = aname or "(未知主播)"
|
|
|
result.append(d)
|
|
|
return result
|
|
|
@@ -147,15 +193,18 @@ def fetch_anchor_details(pool, aname: str) -> list[dict]:
|
|
|
aname (str): 主播名 anchor_username。
|
|
|
|
|
|
Returns:
|
|
|
- list[dict]: 每元素为一条明细(团名/编号/卡种/规格/单价/总份数/进度/总金额/参与人数/开售时间/成交时间/售卖时长)。
|
|
|
+ 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 去重(中卡近似,仅覆盖有报告的团;待发货团常尚无报告,为 0)
|
|
|
+ {amount_expr('p')} AS amount,
|
|
|
+ -- 该团中卡人数:拆卡报告 user_name 去重(抽中卡片的人,仅覆盖有报告的团;待发货团常尚无报告,为 0)
|
|
|
(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,
|
|
|
+ -- 该团真实购买人数:订单表按 uid/user_name 去重 + 匿名每笔计 1(未抓订单的团为 0)
|
|
|
+ (SELECT {REAL_BUYERS_EXPR} FROM super_vault_order_record o
|
|
|
+ WHERE o.pid = p.pid) AS real_buyers,
|
|
|
p.sell_time,
|
|
|
p.sold_time AS deal_time,
|
|
|
TIMESTAMPDIFF(SECOND, p.sell_time, p.sold_time) AS duration_secs
|
|
|
@@ -165,13 +214,14 @@ def fetch_anchor_details(pool, aname: str) -> list[dict]:
|
|
|
"""
|
|
|
rows = pool.select_all(sql, (aname,)) or []
|
|
|
result = []
|
|
|
- for (title, serial, type_name, standard, price, sold, total, amount, buyers,
|
|
|
+ for (title, serial, type_name, standard, price, sold, total, amount, buyers, real_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,
|
|
|
+ "中卡人数": buyers, # 拆卡报告中卡去重(原「参与人数」,非真实参与者)
|
|
|
+ "真实购买人数": int(real_buyers or 0), # 订单表真实买家去重(Decimal 转 int)
|
|
|
# 成交时间 = 售罄成交时刻 sold_time;售卖时长 = sold_time − 开售时间
|
|
|
"开售时间": _fmt_dt(sell_time), "成交时间": _fmt_dt(deal_time),
|
|
|
"售卖时长": _fmt_duration(duration_secs),
|
|
|
@@ -179,6 +229,121 @@ def fetch_anchor_details(pool, aname: str) -> list[dict]:
|
|
|
return result
|
|
|
|
|
|
|
|
|
+# 「用户明细」Sheet 列规格:(表头, 取值键, 是否金额格式);「排名」复用 _write_details 的行号自增
|
|
|
+USER_DETAIL_COLS = [
|
|
|
+ ("排名", "序号", False), ("用户昵称", "用户昵称", False), ("user_id", "user_id", False),
|
|
|
+ ("参与车数", "参与车数", False), ("参与金额", "参与金额", True), ("车均消费", "车均消费", True),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def _share_expr() -> str:
|
|
|
+ """生成「按团实收分摊到份」的单用户金额 SQL 表达式。
|
|
|
+
|
|
|
+ 用户在某团的花费 = 团实收(优先 total_price,缺失回退满仓价)× 该用户份数 ÷ 团总份数,
|
|
|
+ 与平台/主播汇总的 total_price 实收口径一致,避免用户明细金额合计与平台销售额对不上。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ str: 可嵌入 SUM() 的金额分摊表达式。
|
|
|
+ """
|
|
|
+ # NULLIF 防团总份数为 0 时除零(售罄团 count>0,仅兜底)
|
|
|
+ return f"({amount_expr('p')}) * o.count / NULLIF(p.count, 0)"
|
|
|
+
|
|
|
+
|
|
|
+def fetch_user_details(pool) -> list[dict]:
|
|
|
+ """统计昨天成交团的真实买家明细(非匿名),按参与金额倒序、全部返回。
|
|
|
+
|
|
|
+ 身份口径与报告「真实购买人数」一致:登录用户按 uid、默认头像用户按 user_name 去重
|
|
|
+ (GROUP BY COALESCE(uid, user_name))。匿名订单无稳定身份,不在此列(见 fetch_anon_summary)。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ list[dict]: 每元素为 {用户昵称, user_id, 参与车数, 参与金额, 车均消费};无数据时为空列表。
|
|
|
+ """
|
|
|
+ sql = f"""
|
|
|
+ SELECT
|
|
|
+ MAX(o.user_name) AS user_name,
|
|
|
+ MAX(o.uid) AS uid,
|
|
|
+ COUNT(DISTINCT o.pid) AS cars,
|
|
|
+ ROUND(SUM({_share_expr()}), 2) AS amount
|
|
|
+ FROM super_vault_order_record o
|
|
|
+ JOIN super_vault_product_record p ON p.pid = o.pid
|
|
|
+ WHERE {win_clause('p')} AND o.is_anonymous = 0
|
|
|
+ GROUP BY COALESCE(o.uid, o.user_name)
|
|
|
+ ORDER BY amount DESC
|
|
|
+ """
|
|
|
+ rows = pool.select_all(sql) or []
|
|
|
+ result = []
|
|
|
+ for user_name, uid, cars, amount in rows:
|
|
|
+ amount = float(amount or 0)
|
|
|
+ result.append({
|
|
|
+ "用户昵称": user_name,
|
|
|
+ "user_id": uid, # 默认头像用户无 uid,留空
|
|
|
+ "参与车数": cars,
|
|
|
+ "参与金额": amount,
|
|
|
+ "车均消费": round(amount / cars, 2) if cars else None, # 车均 = 参与金额 ÷ 参与车数
|
|
|
+ })
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def fetch_anon_summary(pool) -> tuple[int, float]:
|
|
|
+ """统计昨天成交团的匿名订单笔数与合计金额(无稳定身份、无法去重,仅汇总展示)。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ tuple[int, float]: (匿名订单笔数, 匿名合计金额)。
|
|
|
+ """
|
|
|
+ sql = f"""
|
|
|
+ SELECT COUNT(*) AS cnt, ROUND(SUM({_share_expr()}), 2) AS amount
|
|
|
+ FROM super_vault_order_record o
|
|
|
+ JOIN super_vault_product_record p ON p.pid = o.pid
|
|
|
+ WHERE {win_clause('p')} AND o.is_anonymous = 1
|
|
|
+ """
|
|
|
+ cnt, amount = pool.select_all(sql)[0]
|
|
|
+ return int(cnt or 0), float(amount or 0)
|
|
|
+
|
|
|
+
|
|
|
+def build_user_sheet(wb, pool):
|
|
|
+ """在工作簿追加「用户明细」Sheet:真实买家按参与金额倒序排行,匿名单独汇总。
|
|
|
+
|
|
|
+ 金额口径与主报告一致(团实收 total_price 分摊到份),故本表金额合计 ≈ 平台销售额。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ wb (Workbook): openpyxl 工作簿(已含主报告 Sheet)。
|
|
|
+ pool (MySQLConnectionPool): MySQL 连接池。
|
|
|
+ """
|
|
|
+ users = fetch_user_details(pool)
|
|
|
+ anon_cnt, anon_amount = fetch_anon_summary(pool)
|
|
|
+
|
|
|
+ ws = wb.create_sheet("用户明细")
|
|
|
+ span = len(USER_DETAIL_COLS)
|
|
|
+ r = 1
|
|
|
+
|
|
|
+ # 标题行
|
|
|
+ ws.cell(row=r, column=1,
|
|
|
+ value=(f"用户明细 · 超新星(共 {len(users)} 人参与,全部展示,按参与金额倒序;"
|
|
|
+ f"金额按团实收 total_price 分摊到份)"))
|
|
|
+ _style_row(ws, r, span, font=FONT_TITLE, border=False)
|
|
|
+ r += 1
|
|
|
+
|
|
|
+ # 副标题:匿名汇总(匿名无稳定身份、不进上方排行,单独说明)
|
|
|
+ ws.cell(row=r, column=1,
|
|
|
+ value=f"另有匿名订单 {anon_cnt} 笔 / 合计 {anon_amount:,.2f}(无稳定身份,未计入上方排行)")
|
|
|
+ _style_row(ws, r, span, font=base.FONT_HEADER, border=False)
|
|
|
+ r += 2 # 留一空行
|
|
|
+
|
|
|
+ # 排行表(复用明细渲染组件,「排名」列由 _write_details 按行号自增)
|
|
|
+ _write_details(ws, r, users, USER_DETAIL_COLS)
|
|
|
+
|
|
|
+ # 列宽 + 冻结标题/表头(标题1 / 副标题2 / 空行3 / 表头4 / 数据5+)
|
|
|
+ for i, w in enumerate([8, 22, 16, 12, 16, 14]):
|
|
|
+ ws.column_dimensions[chr(65 + i)].width = w
|
|
|
+ ws.freeze_panes = "A5"
|
|
|
+
|
|
|
+
|
|
|
def build_report(pool, out: str):
|
|
|
"""汇总各段数据并生成单 Sheet 分区 Excel(超新星·已售每日报告)。
|
|
|
|
|
|
@@ -218,24 +383,72 @@ def build_report(pool, out: str):
|
|
|
details = fetch_anchor_details(pool, a["主播名"])
|
|
|
ws.cell(row=r, column=1,
|
|
|
value=(f" ▼ {a['主播名']} 销售额 {a['销售额']:,.2f} / 成团 {a['成团数']} / "
|
|
|
- f"参与 {a['参与人数']}(共 {len(details)} 条明细)"))
|
|
|
+ f"中卡 {a['中卡人数']} / 真实 {a['真实购买人数']}(共 {len(details)} 条明细)"))
|
|
|
_style_row(ws, r, SECTION_SPAN, font=base.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]
|
|
|
+ # 列宽:在原明细列基础上,「中卡人数」后插入「真实购买人数」列宽(14 列)
|
|
|
+ widths = [8, 50, 14, 18, 14, 11, 10, 8, 14, 12, 14, 20, 20, 14]
|
|
|
for i, w in enumerate(widths):
|
|
|
ws.column_dimensions[chr(65 + i)].width = w
|
|
|
ws.freeze_panes = "A2" # 冻结标题行
|
|
|
|
|
|
+ # 追加「用户明细」Sheet(真实买家按参与金额排行 + 匿名汇总),口径同主报告
|
|
|
+ build_user_sheet(wb, pool)
|
|
|
+
|
|
|
wb.save(out)
|
|
|
|
|
|
|
|
|
+def cleanup_old_reports(log, out_dir: str, keep_days: int = KEEP_DAYS) -> int:
|
|
|
+ """清理超出保留期的历史报告 xlsx,只留最近 keep_days 天。
|
|
|
+
|
|
|
+ 保留期按**文件名里的统计日期**判断(而非文件 mtime)——报告可能被重跑、被拷贝,
|
|
|
+ mtime 会变而统计日期不会,用它做基准才和「只留最近 30 天的报告」语义一致。
|
|
|
+ 文件名不符合 {前缀}_YYYYMMDD.xlsx 的一律跳过,避免误删手工文件或其他报表。
|
|
|
+
|
|
|
+ Args:
|
|
|
+ log: 日志对象。
|
|
|
+ out_dir (str): 报告所在目录。
|
|
|
+ keep_days (int, optional): 保留天数,按统计日期算。Defaults to KEEP_DAYS。
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ int: 实际删除的文件数;无可删文件时返回 0。
|
|
|
+ """
|
|
|
+ # 以「今天」为基准倒推保留边界:统计日期早于该日的报告删掉
|
|
|
+ cutoff = (datetime.now() - timedelta(days=keep_days)).date()
|
|
|
+ removed = 0
|
|
|
+ # glob 先粗筛同前缀文件,再用正则严格校验日期段,双重过滤防误删
|
|
|
+ for path in glob.glob(os.path.join(out_dir, f"{OUT_PREFIX}_*.xlsx")):
|
|
|
+ m = OUT_FILE_PATTERN.match(os.path.basename(path))
|
|
|
+ if not m:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ file_date = datetime.strptime(m.group(1), "%Y%m%d").date()
|
|
|
+ except ValueError:
|
|
|
+ # 8 位数字但不是合法日期(如 20261340),当异常文件留着,不猜不删
|
|
|
+ log.warning(f"报告文件名日期非法,跳过清理: {os.path.basename(path)}")
|
|
|
+ continue
|
|
|
+ if file_date >= cutoff:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ os.remove(path)
|
|
|
+ removed += 1
|
|
|
+ log.info(f"清理过期报告: {os.path.basename(path)}")
|
|
|
+ except OSError as e:
|
|
|
+ # 占用中(Excel 打开着)或权限不足:仅告警,下轮再清,不影响报告产出
|
|
|
+ log.warning(f"清理失败 {os.path.basename(path)}: {e}")
|
|
|
+ if removed:
|
|
|
+ log.info(f"过期报告清理完成,共删除 {removed} 个(保留最近 {keep_days} 天)")
|
|
|
+ return removed
|
|
|
+
|
|
|
+
|
|
|
def run_once(log) -> str:
|
|
|
"""连库生成超新星·已售每日报告(统计昨天成交)。
|
|
|
|
|
|
+ 生成并发送后顺带清理超出保留期的历史 xlsx(见 cleanup_old_reports)。
|
|
|
+
|
|
|
Args:
|
|
|
log: 日志对象。
|
|
|
|
|
|
@@ -253,12 +466,18 @@ def run_once(log) -> str:
|
|
|
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}")
|
|
|
+ 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}")
|
|
|
+
|
|
|
+ # 清理过期报告放在发送之后:确保本次产物已送出,清理异常也不会影响报告与推送
|
|
|
+ try:
|
|
|
+ cleanup_old_reports(log, out_dir)
|
|
|
+ except Exception as e:
|
|
|
+ log.warning(f"过期报告清理跳过: {e}")
|
|
|
return out_file
|
|
|
|
|
|
|
|
|
@@ -277,4 +496,10 @@ def schedule_task():
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
- main()
|
|
|
+ # 独立运行才配置日志 sink:按天切分、保留 7 天,与入口脚本同格式同去向(根目录 logs/)
|
|
|
+ logger.remove()
|
|
|
+ logger.add(LOG_PATH, encoding="utf-8", rotation="00:00",
|
|
|
+ format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
|
|
|
+ level="DEBUG", retention="7 day")
|
|
|
+ # main()
|
|
|
+ schedule_task()
|