weekly_report.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/24
  5. """得卡 DECA · 魔都兄弟球星卡「周报」统计(单 Sheet:魔都明细)。
  6. 在每日报告(daily_report.py)之外新增的每周任务:把「魔都明细」sheet 的口径由「单日单场」
  7. 放宽到「上一个完整自然周(周一~周日)」,统计该周内魔都(881226408)所有成交组队(拼团商品)。
  8. 与每日报告的关系:
  9. - 明细列、汇总块、样式、里程碑用时算法,全部直接复用 daily_report,本文件不重复实现
  10. 渲染/样式,只重写 2 个「周口径」取数函数(把时间窗 WIN_P 换成 WIN_W)。
  11. - 不含每日报告魔都明细尾部的「购买记录覆盖检测」小节(主公要求周报去掉)。
  12. - daily_report.py 一个字不改;本文件为纯新增。
  13. 时间窗口(WIN_W):上一个自然周 [上周一 00:00:00, 本周一 00:00:00)(左闭右开,含上周一~上周日
  14. 整 7 天,按 completed_at 自然日历切分)。基准用 MySQL WEEKDAY()(0=周一..6=周日)从
  15. CURDATE() 回退到本周一,再减 7 天得上周一——故本任务定在每周一早上跑,正好汇总刚结束的完整周。
  16. 注:魔都夜间场常成交到次日凌晨,按自然日历切分时,某周日夜场溢出到周一 00:xx 的团会计入
  17. 「下一周」,此为主公选定的自然周(周一~周日)口径,非漏统计。
  18. 明细列(16 列,与 daily_report 魔都明细 sheet 完全一致,见 daily_report.MODDU_DETAIL_COLS):
  19. 序号/团名(商品标题)/系列/类型/单价/总份数/进度%/总金额/参与人数(本团)/中卡人数/
  20. 开售时间/成交时间/售卖时长/到25%用时/到50%用时/到75%用时
  21. 口径说明(同 daily_report):
  22. - 销售额 = SUM(COALESCE(team_total_amount, sold_count * unit_price))(随机团按 teams 精算)。
  23. - 成团数 = 该周成交的拼团商品数。
  24. - 参与人数(本团) = 各团 deca_buy_record 去重买家 user_id;汇总「参与人数(真实买家)」= 跨周内
  25. 全部成交团去重(故明细逐团相加人次 ≥ 汇总去重人头)。
  26. - 中卡人数 = 该团拆卡报告 hit_user_nickname 去重(中卡近似)。
  27. - 到 25/50/75% 用时 = deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
  28. 首张快照已越阈值(坍缩)则留空。progress 表 2026/08/11 上线,更早的团相应列可能为空。
  29. 从 stats 目录运行:python weekly_report.py(cwd=stats,mysql_pool 读 stats/application.yml)。
  30. """
  31. import os
  32. import sys
  33. import time
  34. from datetime import timedelta
  35. import schedule
  36. from loguru import logger
  37. from openpyxl import Workbook
  38. # 把项目根目录加入 import 路径(与 daily_report 一致,便于潜在的根目录模块导入)
  39. BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  40. sys.path.insert(0, os.path.dirname(BASE_DIR))
  41. from mysql_pool import MySQLConnectionPool
  42. # 复用每日报告的渲染层/样式/列规格/无窗口依赖的纯算法(本文件不重复实现这些)
  43. from daily_report import (
  44. MODDU_MID, MODDU_DETAIL_COLS, DETAIL_WIDTHS_MODDU,
  45. _pack_summary, _build_detail_sheet,
  46. )
  47. # 日志:按天切分文件,保留 7 天(常驻定时运行)。放本文件所在目录的 logs/,不依赖 cwd。
  48. # 注:导入 daily_report 时其模块级已 logger.add 过每日报告 sink,这里 remove 后只保留周报 sink。
  49. logger.remove()
  50. logger.add(os.path.join(BASE_DIR, "logs", "weekly_report_{time:YYYYMMDD}.log"),
  51. encoding="utf-8", rotation="00:00",
  52. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}",
  53. level="INFO", retention="7 day")
  54. OUT_PREFIX = "得卡-魔都-已售周报告" # 输出文件名前缀(只查魔都一家),后缀加「上周一_上周日」两个日期
  55. # 企微发送:报告生成后把 Excel 发到企业微信群机器人(群由 auto_send_wx_msg.WEBHOOK_URL 决定,与每日报告同群)
  56. SEND_WECHAT = True
  57. # ---- 周时间窗(WIN_W):上一个自然周 [上周一 00:00:00, 本周一 00:00:00) 左闭右开 ----
  58. # 本周一:WEEKDAY() 0=周一..6=周日,从今天回退到本周一 00:00:00(DATE,不含时分秒即 00:00:00)
  59. _THIS_MONDAY = "(CURDATE() - INTERVAL WEEKDAY(CURDATE()) DAY)"
  60. # 上周一 = 本周一 - 7 天
  61. _LAST_MONDAY = f"({_THIS_MONDAY} - INTERVAL 7 DAY)"
  62. def _win(alias: str) -> str:
  63. """生成某表别名在「上一个自然周」窗口内的 completed_at 过滤子句。
  64. Args:
  65. alias (str): SQL 中 deca_product_record 的表别名(如 "p" / "pp")。
  66. Returns:
  67. str: 形如 "p.completed_at >= 上周一 AND p.completed_at < 本周一" 的过滤子句。
  68. """
  69. return (f"{alias}.completed_at >= {_LAST_MONDAY} "
  70. f"AND {alias}.completed_at < {_THIS_MONDAY}")
  71. WIN_W = _win("p") # 主表 p 的周窗口子句(供各取数 SQL 拼接)
  72. def get_week_window(pool):
  73. """取上一个自然周的起止日期(供报告标题与文件名展示)。
  74. Args:
  75. pool (MySQLConnectionPool): MySQL 连接池。
  76. Returns:
  77. tuple[date, date]: (上周一 date, 上周日 date)。上周日 = 本周一 - 1 天。
  78. """
  79. wk_start, this_monday = pool.select_all(
  80. f"SELECT {_LAST_MONDAY}, {_THIS_MONDAY}")[0]
  81. last_sunday = this_monday - timedelta(days=1) # 本周一(右开界) 前一天即上周日
  82. return wk_start, last_sunday
  83. def fetch_moddu_summary_week(pool, mid: str) -> dict:
  84. """统计魔都商家「上一个自然周」的汇总(销售额/成团数/参与人数/均拼单价/人均消费)。
  85. 参与人数用 deca_buy_record 去重真实买家(跨周内全部成交团),人均消费随之按真实人头计。
  86. Args:
  87. pool (MySQLConnectionPool): MySQL 连接池。
  88. mid (str): 商家 merchant_user_id。
  89. Returns:
  90. dict: 含 商家名/商家ID/销售额/成团数/参与人数/均拼单价/人均消费。
  91. """
  92. sql = f"""
  93. SELECT
  94. MAX(p.merchant_name) AS mname,
  95. ROUND(SUM(COALESCE(p.team_total_amount, p.sold_count * p.unit_price)), 2) AS amount,
  96. COUNT(*) AS grp
  97. FROM deca_product_record p
  98. WHERE p.merchant_user_id = %s AND {WIN_W}
  99. AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
  100. """
  101. mname, amount, groups = pool.select_all(sql, (mid,))[0]
  102. # 参与人数(真实买家) = 周内该商家全部成交团的 deca_buy_record 去重 user_id
  103. people = pool.select_all(f"""
  104. SELECT COUNT(DISTINCT b.user_id)
  105. FROM deca_buy_record b
  106. JOIN deca_product_record pp ON pp.product_code = b.product_code
  107. WHERE pp.merchant_user_id = %s AND {_win('pp')}
  108. """, (mid,))[0][0] or 0
  109. d = _pack_summary(amount, groups, people)
  110. d["商家名"] = mname or mid
  111. d["商家ID"] = mid
  112. return d
  113. def fetch_moddu_details_week(pool, mid: str) -> list[dict]:
  114. """取魔都商家「上一个自然周」内每个拼团(组队)的扩展明细,按总金额倒序。
  115. 列与算法同 daily_report.fetch_moddu_details,仅时间窗由单日单场换为上一个自然周(WIN_W):
  116. - 参与人数:deca_buy_record 去重买家 user_id(本团真实参团人头)。
  117. - 到 25/50/75% 用时:deca_onsale_product_progress_record 首次 pct≥X 的快照时刻 − 开售时间;
  118. 首张快照已越阈值(坍缩)则留空(判定见 daily_report._milestone_used)。
  119. Args:
  120. pool (MySQLConnectionPool): MySQL 连接池。
  121. mid (str): 商家 merchant_user_id。
  122. Returns:
  123. list[dict]: 每条含 团名/系列/类型/单价/总份数/进度/总金额/参与人数/中卡人数/开售时间/
  124. 成交时间/售卖时长/到25%用时/到50%用时/到75%用时。
  125. """
  126. # 里程碑/时长算法直接复用 daily_report(避免重复实现坍缩判定逻辑)
  127. from daily_report import _fmt_duration, _milestone_used
  128. sql = f"""
  129. SELECT
  130. p.title, p.series_name, p.spec_name, p.unit_price, p.sold_count, p.card_count,
  131. ROUND(COALESCE(p.team_total_amount, p.sold_count * p.unit_price), 2) AS amount,
  132. p.sale_start_at, p.completed_at,
  133. TIMESTAMPDIFF(SECOND, p.sale_start_at, p.completed_at) AS duration_secs,
  134. (SELECT COUNT(DISTINCT b.user_id) FROM deca_buy_record b
  135. WHERE b.product_code = p.product_code) AS buyers,
  136. -- 中卡人数:该团拆卡报告去重命中用户(hit_user_nickname),中卡近似口径
  137. (SELECT COUNT(DISTINCT r.hit_user_nickname) FROM deca_report_record r
  138. WHERE r.product_code = p.product_code
  139. AND r.hit_user_nickname IS NOT NULL AND r.hit_user_nickname <> '') AS hit_users,
  140. -- 该商品最早一条进度快照时刻:用于判定里程碑是否「坍缩」(首张快照已越阈值则用时不可信)
  141. (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
  142. WHERE pr.product_code = p.product_code) AS first_cap,
  143. (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
  144. WHERE pr.product_code = p.product_code AND pr.progress_pct >= 25) AS t25,
  145. (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
  146. WHERE pr.product_code = p.product_code AND pr.progress_pct >= 50) AS t50,
  147. (SELECT MIN(pr.captured_at) FROM deca_onsale_product_progress_record pr
  148. WHERE pr.product_code = p.product_code AND pr.progress_pct >= 75) AS t75
  149. FROM deca_product_record p
  150. WHERE p.merchant_user_id = %s AND {WIN_W}
  151. AND p.unit_price IS NOT NULL AND p.sold_count IS NOT NULL
  152. ORDER BY amount DESC
  153. """
  154. rows = pool.select_all(sql, (mid,)) or []
  155. result = []
  156. for (title, series, spec, price, sold, card, amount, start, completed,
  157. duration_secs, buyers, hit_users, first_cap, t25, t50, t75) in rows:
  158. progress = round(sold / card * 100, 1) if card else None # 售卖进度百分比
  159. result.append({
  160. "团名": title, "系列": series, "类型": spec, "单价": price,
  161. "总份数": card, "进度": progress, "总金额": amount,
  162. "参与人数": buyers,
  163. "中卡人数": hit_users, # 该团拆卡报告去重命中用户(中卡近似)
  164. "开售时间": start, "成交时间": completed, # 开售=sale_start_at,成交=completed_at
  165. "售卖时长": _fmt_duration(duration_secs), # 成交-开售,也即整团总时长
  166. # 到 X% 用时:仅当 tX 之前还有更早快照(未坍缩)时才输出真实穿越耗时,否则留空
  167. "到25%用时": _milestone_used(t25, first_cap, start),
  168. "到50%用时": _milestone_used(t50, first_cap, start),
  169. "到75%用时": _milestone_used(t75, first_cap, start),
  170. })
  171. return result
  172. def build_week_report(pool, out: str, title: str):
  173. """生成单 Sheet(魔都明细)周报 Excel。
  174. 仅一个 sheet「魔都明细」:汇总块 + 每条组队明细(16 列),结构/样式复用
  175. daily_report._build_detail_sheet。不含「购买记录覆盖检测」小节(miss_info=None)。
  176. Args:
  177. pool (MySQLConnectionPool): MySQL 连接池。
  178. out (str): 导出的 xlsx 路径。
  179. title (str): sheet 顶部分区标题(含商家名与周成交时间窗)。
  180. """
  181. summ = fetch_moddu_summary_week(pool, MODDU_MID)
  182. details = fetch_moddu_details_week(pool, MODDU_MID)
  183. wb = Workbook()
  184. ws = wb.active
  185. ws.title = "魔都明细"
  186. # is_real=True(魔都为真实买家口径);span/widths 取魔都明细专属规格;
  187. # miss_info=None → 不输出「购买记录覆盖检测」小节(主公要求周报去掉此块)
  188. _build_detail_sheet(ws, title, summ, details, MODDU_DETAIL_COLS,
  189. True, len(MODDU_DETAIL_COLS), DETAIL_WIDTHS_MODDU, miss_info=None)
  190. wb.save(out)
  191. def run_once(log) -> str:
  192. """连库生成上一个自然周的魔都明细周报,落地到 stats 目录并发送到企业微信群。
  193. Args:
  194. log: 日志对象。
  195. Returns:
  196. str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
  197. """
  198. log.info("开始生成魔都周报" + "." * 30)
  199. pool = MySQLConnectionPool(log=log)
  200. if not pool.check_pool_health():
  201. log.error("数据库连接池异常")
  202. return ""
  203. wk_start, last_sunday = get_week_window(pool)
  204. title = (f"魔都兄弟球星卡 · 周汇总"
  205. f"(成交自然周 {wk_start} 00:00:00 ~ {last_sunday} 23:59:59)")
  206. # 输出锚定到本脚本所在目录(stats),文件名带「上周一_上周日」两个日期,便于归档区分
  207. out_file = os.path.join(BASE_DIR, f"{OUT_PREFIX}_{wk_start:%Y%m%d}_{last_sunday:%Y%m%d}.xlsx")
  208. build_week_report(pool, out_file, title)
  209. log.info(f"周报已生成 -> {out_file}")
  210. # 发企微群(只发 Excel;失败仅告警,不影响报告产出)——与每日报告同群同方式
  211. if SEND_WECHAT:
  212. try:
  213. from auto_send_wx_msg import send_wechat_group_file
  214. send_wechat_group_file(log=log, file_path=out_file) # 只发 Excel,不发图
  215. except Exception as e:
  216. log.warning(f"企微发送跳过: {e}")
  217. return out_file
  218. def main():
  219. """命令行一次性生成(手动/调试用)。"""
  220. run_once(logger)
  221. def schedule_task():
  222. """定时入口:每周一 09:20 生成上一个自然周的魔都明细周报。
  223. 错开每日报告(09:10)10 分钟;周一早上跑正好汇总刚结束的完整自然周(上周一~周日)。
  224. """
  225. # run_once(logger) # 立即跑一次(调试时取消注释)
  226. schedule.every().monday.at("09:20").do(run_once, logger)
  227. while True:
  228. schedule.run_pending()
  229. time.sleep(1)
  230. if __name__ == "__main__":
  231. schedule_task()