jw_sold_report.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/19
  5. """集物星球已售日报:只读 jw_sold_product_record / jw_player_record,生成 Excel 发企微群。
  6. 排版与口径对齐参考项目 deca 的《已售每日统计报告》(8→9 sheet):只读库不触发抓取。
  7. 业务日窗口:成交完成时间落在 [昨天17:00:00, 今天06:00:00](含两端)算「昨天」的已售(对齐 deca 的
  8. completed_at 口径,凌晨仍开播故终点延到 06:00)。成交完成时间取 finish_time(结束),为空退回 soldout_time(售罄)。
  9. 报告 09:10 跑,已售采集脚本 08:00 先落库(窗口 06:00 关闭后再采)。
  10. 9 个 sheet:平台总览 / 产品系列榜 / 商家GMV榜 / 运营节奏 / Jake明细 / Jake用户排行榜 /
  11. 九叔明细 / 九叔用户排行榜 / 其他商家。
  12. 与 deca 差异:集物两重点商家(Jake/九叔)都有真实购买记录,故「参与人数」全用真实去重买家(deca 对非重点
  13. 商家用中卡近似)、两商家各出一个用户排行榜;集物未采进度轨迹,故明细无「到25/50/75%用时」里程碑列。
  14. 金额字段库中已是元(入库时已换算),直接展示。
  15. """
  16. import os
  17. import sys
  18. import time
  19. from datetime import date, datetime, timedelta
  20. import schedule
  21. from loguru import logger
  22. from mysql_pool import MySQLConnectionPool
  23. import auto_send_wx_msg as wx
  24. import jw_report_excel as xl
  25. PRODUCT_TYPE_NAME = {"1": "福袋", "2": "变风盒", "3": "错版卡", "4": "原盒"}
  26. # 重点关注商家:(corpInfoId, 展示名, sheet名前缀),各出「{前缀}明细」+「{前缀}用户排行榜」
  27. FOCUS_CORPS = [(100716, "Jake球星卡", "Jake"), (100715, "九叔的喷火龙", "九叔")]
  28. # 已售业务日窗口:成交完成时间落在 [昨17:00, 今06:00](含两端);成交完成时间取 finish_time(结束),
  29. # 为空退回 soldout_time(售罄)。对齐参考项目 deca 的 completed_at 口径。
  30. WIN_SOLD = ("COALESCE(finish_time, soldout_time) >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR "
  31. "AND COALESCE(finish_time, soldout_time) <= CURDATE() + INTERVAL 6 HOUR")
  32. # 昨日同窗口(用于组齐环比):[前天17:00, 昨天06:00],与 WIN_SOLD 整体平移一天、口径一致
  33. WIN_SOLD_YDAY = ("COALESCE(finish_time, soldout_time) >= (CURDATE() - INTERVAL 2 DAY) + INTERVAL 17 HOUR "
  34. "AND COALESCE(finish_time, soldout_time) <= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 6 HOUR")
  35. TOP_SERIES = 15 # 产品系列榜展示上限
  36. TOP_MERCHANT = 10 # 商家GMV榜展示上限
  37. CONC_TOPS = (1, 3, 5, 10) # GMV集中度档位 TopK
  38. HOUR_DIST_DAYS = 7 # 成交时段分布统计近 N 天
  39. OUT_DIR = "./reports"
  40. OUT_PREFIX = "集物星球已售日报"
  41. SEND_WECHAT = True
  42. REPORT_TIME = "09:10" # 晚于在售日报 10 分钟,避开并发
  43. logger.remove()
  44. logger.add("./logs/sold_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  45. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}", level="DEBUG", retention="7 day")
  46. def yuan(raw) -> float | None:
  47. """把库中金额(已是元, DECIMAL)转 float 供 Excel。
  48. Args:
  49. raw: 库中金额值(Decimal/数值/None)。
  50. Returns:
  51. float | None: 元;空值返回 None。
  52. """
  53. return float(raw) if raw is not None else None
  54. def fmt_dt(v) -> str:
  55. """把时间字段格式化为 "YYYY-MM-DD HH:MM"。
  56. Args:
  57. v (datetime | str | None): 时间值。
  58. Returns:
  59. str: 格式化字符串;空值返回空串。
  60. """
  61. if not v:
  62. return ""
  63. if isinstance(v, datetime):
  64. return v.strftime("%Y-%m-%d %H:%M")
  65. return str(v)
  66. def _duration(start, end) -> str:
  67. """算售卖时长文案(成交时间 - 开售时间),格式 "X小时Y分"(对齐 deca)。
  68. Args:
  69. start (datetime | None): 开售时间。
  70. end (datetime | None): 成交时间。
  71. Returns:
  72. str: 形如 "72小时13分";缺失/异常返回空串。
  73. """
  74. if not (isinstance(start, datetime) and isinstance(end, datetime)):
  75. return ""
  76. secs = (end - start).total_seconds()
  77. if secs < 0:
  78. return ""
  79. return f"{int(secs // 3600)}小时{int((secs % 3600) // 60)}分"
  80. def fmt_pct_change(today: float, yday: float) -> str:
  81. """算今日相对昨日同窗口的环比涨跌百分比文案。
  82. Args:
  83. today (float): 今日值。
  84. yday (float): 昨日同窗口值。
  85. Returns:
  86. str: 形如 "+12.3%"/"-5.0%";昨日为 0 返回 "—"。
  87. """
  88. if not yday:
  89. return "—"
  90. return f"{(today - yday) / yday * 100:+.1f}%"
  91. def _bar(value: int, vmax: int, width: int = 20) -> str:
  92. """按占最大值比例生成 █ 条形迷你图字符串。
  93. Args:
  94. value (int): 当前值。
  95. vmax (int): 最大值。
  96. width (int, optional): 满格字符数。Defaults to 20。
  97. Returns:
  98. str: █ 组成的条形;value/vmax 为空返回空串。
  99. """
  100. if not vmax or not value:
  101. return ""
  102. return "█" * max(1, round(value / vmax * width))
  103. def _line(ws, row: int, text: str) -> int:
  104. """在 A 列写一行纯文本(供覆盖检测/漏采明细/脚注这类非表格文案)。
  105. Args:
  106. ws: 工作表。
  107. row (int): 行号。
  108. text (str): 文本。
  109. Returns:
  110. int: 下一行号。
  111. """
  112. ws.cell(row=row, column=1, value=text)
  113. return row + 1
  114. def get_window(pool) -> tuple:
  115. """算出已售业务日窗口的起止时刻(供报告展示)。
  116. Args:
  117. pool: 数据库连接池。
  118. Returns:
  119. tuple: (start, end) 两个 "YYYY-MM-DD HH:MM:SS" 字符串,即 [昨17:00, 今06:00]。
  120. """
  121. row = pool.select_all(
  122. "SELECT (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR, CURDATE() + INTERVAL 6 HOUR")[0]
  123. return str(row[0]), str(row[1])
  124. def _sold_gmv(rec: dict) -> tuple:
  125. """由一条已售(成交/组齐)记录算出 (售出份数, GMV元)。
  126. 已售历史 corp/history 里的团都是**成交组齐团**,总份数全部售出,故售出份数 = `stock_amount`
  127. (实测全表 `SUM(购买记录 buy_count) == stock_amount`;该接口 `residueStockAmount` 对成交团恒
  128. 等于总份数、不可用 stock−residue,否则算出售出=0、GMV=0)。GMV = 售出份数 × 单价元。
  129. Args:
  130. rec (dict): fetch_sold_rows 的一条。
  131. Returns:
  132. tuple: (sold_count, gmv_yuan);字段缺失时对应项为 None。
  133. """
  134. stock = rec.get("stock_amount")
  135. amount = rec.get("amount")
  136. sold = stock if isinstance(stock, int) else None # 成交组齐团=全部售出,售出份数=总份数
  137. gmv = (sold * float(amount)) if isinstance(sold, int) and amount is not None else None # amount 已是元
  138. return sold, gmv
  139. def _agg(rows: list) -> dict:
  140. """聚合一批已售记录的核心指标。
  141. Args:
  142. rows (list): fetch_sold_rows 结果的子集。
  143. Returns:
  144. dict: {teams 成团数, merchants 商家数, gmv 总GMV元, avg_unit 均团单价元}。
  145. """
  146. teams = len(rows)
  147. merchants = len({r["corp_info_id"] for r in rows})
  148. gmv = sum(g for _, g in map(_sold_gmv, rows) if g) or 0.0
  149. units = [float(r["amount"]) for r in rows if r.get("amount") is not None]
  150. avg_unit = (sum(units) / len(units)) if units else 0.0
  151. return {"teams": teams, "merchants": merchants, "gmv": gmv, "avg_unit": avg_unit}
  152. def fetch_sold_rows(pool, win: str) -> list:
  153. """取指定业务日窗口内的已售全量(含各字段)。
  154. Args:
  155. pool: 数据库连接池。
  156. win (str): WHERE 时间窗条件(WIN_SOLD / WIN_SOLD_YDAY)。
  157. Returns:
  158. list: 每元素为 dict,含商家/商品/价格/库存/时间/buy_fetched 等字段(金额为元)。
  159. """
  160. recs = pool.select_all(
  161. "SELECT corp_info_id, corp_info_name, goods_id, goods_name, goods_ip_name, gift_series, product_type, "
  162. "specification_name, amount, highest_price, lowest_price, stock_amount, residue_stock_amount, "
  163. "sold_time, soldout_time, finish_time, buy_fetched FROM jw_sold_product_record "
  164. f"WHERE {win} ORDER BY corp_info_id, gmt_create_time DESC")
  165. keys = ["corp_info_id", "corp_info_name", "goods_id", "goods_name", "goods_ip_name", "gift_series",
  166. "product_type", "specification_name", "amount", "highest_price", "lowest_price", "stock_amount",
  167. "residue_stock_amount", "sold_time", "soldout_time", "finish_time", "buy_fetched"]
  168. return [dict(zip(keys, r)) for r in recs]
  169. def fetch_buyers_map(log, pool, goods_ids: list) -> dict:
  170. """批量统计指定商品的购买人数(购买记录去重买家数)。
  171. 数据源 jw_player_record(玩家维度,每商品每买家一行,含真实 userId)。
  172. Args:
  173. log: 日志对象。
  174. pool: 数据库连接池。
  175. goods_ids (list): 商品ID列表。
  176. Returns:
  177. dict: {goods_id: 购买人数};无购买记录的商品不在字典内(取用时默认 0)。
  178. """
  179. if not goods_ids:
  180. return {}
  181. ph = ",".join(["%s"] * len(goods_ids))
  182. rows = pool.select_all(
  183. f"SELECT goods_id, COUNT(DISTINCT user_id) FROM jw_player_record WHERE goods_id IN ({ph}) GROUP BY goods_id",
  184. tuple(goods_ids))
  185. return {r[0]: int(r[1]) for r in rows}
  186. def fetch_corp_distinct_buyers(pool, win: str) -> dict:
  187. """按商家统计业务日窗口内的去重买家数(跨该商家全部成交团去重人头,非人次)。
  188. Args:
  189. pool: 数据库连接池。
  190. win (str): 时间窗条件(WIN_SOLD)。
  191. Returns:
  192. dict: {corp_info_id: 去重买家数}。
  193. """
  194. rows = pool.select_all(
  195. "SELECT s.corp_info_id, COUNT(DISTINCT p.user_id) "
  196. "FROM jw_sold_product_record s JOIN jw_player_record p ON p.goods_id = s.goods_id "
  197. f"WHERE {win} GROUP BY s.corp_info_id")
  198. return {r[0]: int(r[1]) for r in rows}
  199. def fetch_platform_distinct_buyers(pool, win: str) -> int:
  200. """统计业务日窗口内全平台去重买家数(跨所有成交团去重人头)。
  201. Args:
  202. pool: 数据库连接池。
  203. win (str): 时间窗条件(WIN_SOLD)。
  204. Returns:
  205. int: 全平台去重买家数。
  206. """
  207. row = pool.select_all(
  208. "SELECT COUNT(DISTINCT p.user_id) "
  209. "FROM jw_sold_product_record s JOIN jw_player_record p ON p.goods_id = s.goods_id "
  210. f"WHERE {win}")
  211. return int(row[0][0]) if row and row[0][0] is not None else 0
  212. def fetch_completion_hour_dist(pool, days: int) -> list:
  213. """按成交完成时间的小时(0~23)统计近 days 天成交团数分布。
  214. Args:
  215. pool: 数据库连接池。
  216. days (int): 统计近 N 天。
  217. Returns:
  218. list: 长度 24 的列表,索引=小时,值=该小时成交团数。
  219. """
  220. since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
  221. dist = [0] * 24
  222. for h, c in pool.select_all(
  223. "SELECT HOUR(COALESCE(finish_time, soldout_time)) h, COUNT(*) c FROM jw_sold_product_record "
  224. "WHERE COALESCE(finish_time, soldout_time) >= %s GROUP BY h", (since,)) or []:
  225. if h is not None and 0 <= int(h) < 24:
  226. dist[int(h)] = int(c)
  227. return dist
  228. def fetch_user_ranking(pool, corp_id: int) -> list:
  229. """取某重点商家在业务日窗口内的用户消费排行(购买记录 join 已售表算金额)。
  230. Args:
  231. pool: 数据库连接池。
  232. corp_id (int): 商家 corpInfoId。
  233. Returns:
  234. list: 每元素 (user_id, user_nick, 参与车数, 参与金额元),按金额倒序。
  235. """
  236. return pool.select_all(
  237. "SELECT b.user_id, MAX(b.user_nick) nick, COUNT(DISTINCT b.goods_id) cars, "
  238. "SUM(b.buy_count * s.amount) spent "
  239. "FROM jw_player_record b JOIN jw_sold_product_record s ON s.goods_id = b.goods_id "
  240. f"WHERE s.corp_info_id = %s AND {WIN_SOLD} "
  241. "GROUP BY b.user_id ORDER BY spent DESC", (corp_id,)) or []
  242. def build_overview(wb, all_rows: list, yday_rows: list, platform_buyers: int, window: tuple) -> None:
  243. """写「平台总览」sheet:平台汇总 + 当日组齐环比 + 商家GMV集中度(对齐 deca 排版)。
  244. Args:
  245. wb: 工作簿。
  246. all_rows (list): 今日业务日窗口内已售全量。
  247. yday_rows (list): 昨日同窗口已售全量(算环比)。
  248. platform_buyers (int): 全平台跨团去重买家数(人头,非人次)。
  249. window (tuple): 业务日窗口 (start, end)。
  250. """
  251. t, y = _agg(all_rows), _agg(yday_rows)
  252. buyers_total = platform_buyers
  253. per_cap = (t["gmv"] / buyers_total) if buyers_total else 0.0
  254. ws = xl.add_sheet(wb, "平台总览")
  255. row = xl.write_title(ws, 1, "集物星球 · 已售每日统计报告")
  256. row = _line(ws, row, f"成交时间窗 {window[0]} ~ {window[1]}")
  257. row += 1
  258. row = xl.write_section_title(ws, row, 4, "平台汇总")
  259. row = xl.write_kv(ws, row, [
  260. ("商家数", t["merchants"], "int"),
  261. ("销售额", round(t["gmv"], 2), "money"),
  262. ("成团数", t["teams"], "int"),
  263. ("参与人数", buyers_total, "int"),
  264. ("均拼单价", round(t["avg_unit"], 2), "money"),
  265. ("人均消费", round(per_cap, 2), "money"),
  266. ])
  267. row += 1
  268. # 当日组齐环比(数值预格式化成字符串,避免整数被套用金额格式;同列今日/昨日既有金额又有计数)
  269. def _n(v, money=False):
  270. return f"{v:,.2f}" if money else f"{int(v)}"
  271. row = xl.write_section_title(ws, row, 4, "当日组齐环比(vs 昨日同窗口)")
  272. comp = [
  273. ("组齐GMV", _n(t["gmv"], True), _n(y["gmv"], True), fmt_pct_change(t["gmv"], y["gmv"])),
  274. ("成团数", _n(t["teams"]), _n(y["teams"]), fmt_pct_change(t["teams"], y["teams"])),
  275. ("活跃商家数", _n(t["merchants"]), _n(y["merchants"]), fmt_pct_change(t["merchants"], y["merchants"])),
  276. ("T均单价", _n(t["avg_unit"], True), _n(y["avg_unit"], True), fmt_pct_change(t["avg_unit"], y["avg_unit"])),
  277. ]
  278. row = xl.write_table(ws, row, ["指标", "今日", "昨日", "环比"], comp, ["text", "text", "text", "text"])
  279. row += 1
  280. # 商家 GMV 集中度 Top1/3/5/10
  281. row = xl.write_section_title(ws, row, 4, "商家 GMV 集中度(TopN 占平台组齐总 GMV)")
  282. merch_gmv = {}
  283. for r in all_rows:
  284. _, g = _sold_gmv(r)
  285. merch_gmv[r["corp_info_id"]] = merch_gmv.get(r["corp_info_id"], 0.0) + (g or 0)
  286. gmvs = sorted(merch_gmv.values(), reverse=True)
  287. total = sum(gmvs) or 0
  288. conc = [(f"Top{k} 集中度", (sum(gmvs[:k]) / total) if total else 0) for k in CONC_TOPS]
  289. xl.write_table(ws, row, ["集中度档位", "占平台GMV"], conc, ["text", "pct"])
  290. def build_series(wb, all_rows: list) -> None:
  291. """写「产品系列榜」sheet:按 IP 系列(goods_ip_name)汇总 GMV 倒序取 TopN。
  292. Args:
  293. wb: 工作簿。
  294. all_rows (list): 今日业务日窗口内已售全量。
  295. """
  296. agg = {} # 系列 -> [成团数, GMV]
  297. for r in all_rows:
  298. _, g = _sold_gmv(r)
  299. key = r.get("gift_series") or "(未分类)"
  300. a = agg.setdefault(key, [0, 0.0])
  301. a[0] += 1
  302. a[1] += (g or 0)
  303. total = sum(v[1] for v in agg.values()) or 0
  304. ranked = sorted(agg.items(), key=lambda kv: kv[1][1], reverse=True)[:TOP_SERIES]
  305. data = [(name, cnt, round(g, 2), (g / total if total else 0)) for name, (cnt, g) in ranked]
  306. ws = xl.add_sheet(wb, "产品系列榜")
  307. ws.column_dimensions["A"].width = 24 # 系列名较长;同时保证 4 列分区标题条罩住整段标题
  308. row = xl.write_section_title(ws, 1, 4, f"产品系列销售榜(当日 Top{TOP_SERIES},按 GMV)")
  309. xl.write_table(ws, row, ["系列", "成团数", "GMV", "占比"], data, ["text", "int", "money", "pct"])
  310. def build_merchant_rank(wb, all_rows: list) -> None:
  311. """写「商家GMV榜」sheet:按商家汇总 GMV 倒序取 TopN。
  312. Args:
  313. wb: 工作簿。
  314. all_rows (list): 今日业务日窗口内已售全量。
  315. """
  316. agg = {} # corp_info_id -> [商家名, 成团数, GMV]
  317. for r in all_rows:
  318. _, g = _sold_gmv(r)
  319. a = agg.setdefault(r["corp_info_id"], [r.get("corp_info_name"), 0, 0.0])
  320. a[1] += 1
  321. a[2] += (g or 0)
  322. total = sum(v[2] for v in agg.values()) or 0
  323. ranked = sorted(agg.values(), key=lambda v: v[2], reverse=True)[:TOP_MERCHANT]
  324. data = [(name, cnt, round(g, 2), (g / total if total else 0)) for name, cnt, g in ranked]
  325. ws = xl.add_sheet(wb, "商家GMV榜")
  326. ws.column_dimensions["A"].width = 22 # 商家名较长;同时保证 4 列分区标题条罩住整段标题
  327. row = xl.write_section_title(ws, 1, 4, f"商家 GMV 榜(当日组齐口径,前 {TOP_MERCHANT})")
  328. xl.write_table(ws, row, ["商家", "成团数", "GMV", "占比"], data, ["text", "int", "money", "pct"])
  329. def build_ops(wb, pool, all_rows: list, window: tuple) -> None:
  330. """写「运营节奏」sheet:重点商家当日运营快照 + 平台成交时段分布(对齐 deca)。
  331. Args:
  332. wb: 工作簿。
  333. pool: 数据库连接池。
  334. all_rows (list): 今日业务日窗口内已售全量。
  335. window (tuple): 业务日窗口 (start, end),用于判「今日新开团」。
  336. """
  337. ws = xl.add_sheet(wb, "运营节奏")
  338. try:
  339. w_start = datetime.strptime(window[0], "%Y-%m-%d %H:%M:%S")
  340. w_end = datetime.strptime(window[1], "%Y-%m-%d %H:%M:%S")
  341. except (ValueError, TypeError):
  342. w_start = w_end = None
  343. row = xl.write_section_title(ws, 1, 4, "重点商家当日运营快照(新开团 / 已组齐 / 规格)")
  344. snap = []
  345. for cid, name, _ in FOCUS_CORPS:
  346. sub = [r for r in all_rows if r["corp_info_id"] == cid]
  347. new_open = sum(1 for r in sub if w_start and isinstance(r.get("sold_time"), datetime)
  348. and w_start <= r["sold_time"] <= w_end)
  349. specs = {}
  350. for r in sub:
  351. s = r.get("specification_name")
  352. if s:
  353. specs[s] = specs.get(s, 0) + 1
  354. spec_txt = " · ".join(f"{k}×{v}" for k, v in sorted(specs.items(), key=lambda x: -x[1])) or "—"
  355. snap.append((name, new_open, len(sub), spec_txt))
  356. row = xl.write_table(ws, row, ["商家", "今日新开团", "已组齐", "规格分布"], snap,
  357. ["text", "int", "int", "text"])
  358. row += 1
  359. row = xl.write_section_title(ws, row, 4, f"平台成交时段分布(近 {HOUR_DIST_DAYS} 日 24h 累计)")
  360. dist = fetch_completion_hour_dist(pool, HOUR_DIST_DAYS)
  361. vmax = max(dist) if dist else 0
  362. data = [(f"{h:02d}时", dist[h], _bar(dist[h], vmax)) for h in range(24)]
  363. xl.write_table(ws, row, ["时段", "成团数", "分布"], data, ["text", "int", "text"])
  364. def build_focus_detail(wb, name: str, sheet_name: str, rank_sheet: str, rows: list,
  365. buyers_map: dict, win_text: str, corp_buyers_n: int) -> None:
  366. """写某重点商家「明细」sheet:汇总 + 购买记录覆盖检测 + 每条组队明细(对齐 deca)。
  367. Args:
  368. wb: 工作簿。
  369. name (str): 商家展示名。
  370. sheet_name (str): 本 sheet 名。
  371. rank_sheet (str): 对应的用户排行榜 sheet 名(覆盖检测文案里指引)。
  372. rows (list): 该商家今日已售记录。
  373. buyers_map (dict): 各商品的参与人数映射(供明细列「参与人数(本团)」逐团展示)。
  374. win_text (str): 成交时间窗文案 "start ~ end"(写进汇总标题)。
  375. corp_buyers_n (int): 该商家跨团去重买家数(人头,供汇总块「参与人数(真实买家)」)。
  376. """
  377. ws = xl.add_sheet(wb, sheet_name)
  378. total_gmv = sum(g for _, g in map(_sold_gmv, rows) if g) or 0.0
  379. total_buyers = corp_buyers_n # 跨团去重人头(汇总口径),明细列的「参与人数(本团)」另用 buyers_map 逐团
  380. units = [float(r["amount"]) for r in rows if r.get("amount") is not None]
  381. avg_unit = (sum(units) / len(units)) if units else 0.0
  382. per_cap = (total_gmv / total_buyers) if total_buyers else 0.0
  383. row = xl.write_section_title(ws, 1, 12, f"{name} · 汇总(成交时间窗 {win_text})")
  384. row = xl.write_kv(ws, row, [
  385. ("销售额", round(total_gmv, 2), "money"),
  386. ("成团数", len(rows), "int"),
  387. ("参与人数(真实买家)", total_buyers, "int"),
  388. ("均拼单价", round(avg_unit, 2), "money"),
  389. ("人均消费", round(per_cap, 2), "money"),
  390. ])
  391. row += 1
  392. # 购买记录覆盖检测:buy_fetched=1 表示该团购买记录已采全
  393. fetched = sum(1 for r in rows if r.get("buy_fetched") == 1)
  394. missing = [r for r in rows if r.get("buy_fetched") != 1]
  395. row = xl.write_section_title(ws, row, 12, "购买记录覆盖检测(成交团 vs 已采购买记录)")
  396. row = _line(ws, row, f"成交 {len(rows)} 团 · 采到购买记录 {fetched} 团 · 漏采 {len(missing)} 团"
  397. f"(用户排行见「{rank_sheet}」sheet)")
  398. if missing:
  399. row = _line(ws, row, "漏采明细(下列团未采到购买记录,未计入用户排行):")
  400. for m in missing:
  401. row = _line(ws, row, f" - {m.get('goods_id')} {m.get('goods_name') or ''}")
  402. row += 1
  403. row = xl.write_section_title(ws, row, 12, f"每条组队明细(共 {len(rows)} 条,按总金额倒序)")
  404. headers = ["序号", "团名(商品标题)", "系列", "类型", "单价", "总份数", "进度%", "总金额",
  405. "参与人数(本团)", "开售时间", "成交时间", "售卖时长"]
  406. col_types = ["int", "text", "text", "text", "money", "int", "pct", "money",
  407. "int", "text", "text", "text"]
  408. detailed = sorted(rows, key=lambda r: (_sold_gmv(r)[1] or 0), reverse=True)
  409. data = []
  410. for i, r in enumerate(detailed, 1):
  411. sold, gmv = _sold_gmv(r)
  412. stock = r.get("stock_amount")
  413. prog = (sold / stock) if isinstance(sold, int) and isinstance(stock, int) and stock > 0 else 0
  414. ct = r.get("finish_time") or r.get("soldout_time") # 成交时间
  415. data.append((i, r.get("goods_name"), r.get("gift_series"),
  416. PRODUCT_TYPE_NAME.get(str(r.get("product_type")), r.get("product_type")),
  417. yuan(r.get("amount")), stock, prog, gmv,
  418. buyers_map.get(r["goods_id"], 0),
  419. fmt_dt(r.get("sold_time")), fmt_dt(ct), _duration(r.get("sold_time"), ct)))
  420. xl.write_table(ws, row, headers, data, col_types)
  421. def build_user_ranking(wb, name: str, sheet_name: str, corp_id: int, pool) -> None:
  422. """写某重点商家「用户排行榜」sheet:买家按消费金额倒序(对齐 deca)。
  423. Args:
  424. wb: 工作簿。
  425. name (str): 商家展示名。
  426. sheet_name (str): 工作表名。
  427. corp_id (int): 商家 corpInfoId。
  428. pool: 数据库连接池。
  429. """
  430. rows = fetch_user_ranking(pool, corp_id)
  431. data = []
  432. for i, (uid, nick, cars, spent) in enumerate(rows, 1):
  433. spent_f = float(spent) if spent is not None else 0.0
  434. avg = (spent_f / cars) if cars else 0.0
  435. data.append((i, nick or "-", uid, cars, round(spent_f, 2), round(avg, 2)))
  436. ws = xl.add_sheet(wb, sheet_name)
  437. row = xl.write_section_title(
  438. ws, 1, 6, f"用户排行榜 · {name}(共 {len(rows)} 人参与,全部展示,按参与金额倒序)")
  439. row = xl.write_table(ws, row, ["排名", "用户昵称", "user_id", "参与车数", "参与金额", "车均消费"],
  440. data, ["int", "text", "text", "int", "money", "money"])
  441. _line(ws, row, "注:参与金额 = Σ(购买份数 × 团单价);参与车数 = 参与的不同团数;车均消费 = 参与金额 ÷ 参与车数。")
  442. def build_others(wb, rows: list, corp_buyers: dict) -> None:
  443. """写「其他商家」sheet:非重点商家按商家汇总(销售额倒序,对齐 deca;人数用真实去重买家)。
  444. Args:
  445. wb: 工作簿。
  446. rows (list): 非重点商家的今日已售记录。
  447. corp_buyers (dict): {corp_info_id: 跨团去重买家数}(人头)。
  448. """
  449. agg = {} # corp_info_id -> [商家名, 成团数, GMV, [单价...]]
  450. for r in rows:
  451. _, g = _sold_gmv(r)
  452. a = agg.setdefault(r["corp_info_id"], [r.get("corp_info_name"), 0, 0.0, []])
  453. a[1] += 1
  454. a[2] += (g or 0)
  455. a[3].append(r.get("amount"))
  456. data = []
  457. for cid, (name, cnt, g, amts) in agg.items():
  458. buyers = corp_buyers.get(cid, 0) # 跨团去重人头
  459. units = [float(a) for a in amts if a is not None]
  460. avg = (sum(units) / len(units)) if units else 0.0
  461. per = (g / buyers) if buyers else 0.0
  462. data.append((name, round(g, 2), cnt, buyers, round(avg, 2), round(per, 2)))
  463. data.sort(key=lambda x: x[1], reverse=True)
  464. ws = xl.add_sheet(wb, "其他商家")
  465. row = xl.write_section_title(ws, 1, 6, f"其他商家汇总(共 {len(data)} 家,按销售额倒序)")
  466. xl.write_table(ws, row, ["商家名", "销售额", "成团数", "参与人数", "均拼单价", "人均消费"],
  467. data, ["text", "money", "int", "int", "money", "money"])
  468. def build_report(log, pool, out_file: str) -> None:
  469. """汇总 9 个 sheet 生成已售日报 Excel(排版对齐 deca)。
  470. Args:
  471. log: 日志对象。
  472. pool: 数据库连接池。
  473. out_file (str): 输出文件路径。
  474. """
  475. all_rows = fetch_sold_rows(pool, WIN_SOLD)
  476. yday_rows = fetch_sold_rows(pool, WIN_SOLD_YDAY)
  477. window = get_window(pool)
  478. win_text = f"{window[0]} ~ {window[1]}" # 明细 sheet 标题复用
  479. buyers_map = fetch_buyers_map(log, pool, [r["goods_id"] for r in all_rows]) # 各团去重买家(明细列用)
  480. corp_buyers = fetch_corp_distinct_buyers(pool, WIN_SOLD) # 各商家跨团去重人头
  481. platform_buyers = fetch_platform_distinct_buyers(pool, WIN_SOLD) # 全平台跨团去重人头
  482. focus_ids = {cid for cid, _, _ in FOCUS_CORPS}
  483. wb = xl.new_workbook()
  484. build_overview(wb, all_rows, yday_rows, platform_buyers, window) # 1 平台总览
  485. build_series(wb, all_rows) # 2 产品系列榜
  486. build_merchant_rank(wb, all_rows) # 3 商家GMV榜
  487. build_ops(wb, pool, all_rows, window) # 4 运营节奏
  488. for cid, name, prefix in FOCUS_CORPS: # 5-8 重点商家明细+用户榜
  489. sub = [r for r in all_rows if r["corp_info_id"] == cid]
  490. build_focus_detail(wb, name, f"{prefix}明细", f"{prefix}用户排行榜", sub,
  491. buyers_map, win_text, corp_buyers.get(cid, 0))
  492. build_user_ranking(wb, name, f"{prefix}用户排行榜", cid, pool)
  493. others = [r for r in all_rows if r["corp_info_id"] not in focus_ids]
  494. build_others(wb, others, corp_buyers) # 9 其他商家
  495. xl.save(wb, out_file)
  496. def run_once(log) -> str:
  497. """生成一次已售日报并发企微(只读库;DB 异常则跳过本轮)。
  498. Args:
  499. log: 日志对象。
  500. Returns:
  501. str: 生成的 Excel 路径;失败返回空串。
  502. """
  503. log.info("开始生成已售日报" + "." * 30)
  504. pool = MySQLConnectionPool(log=log)
  505. if not pool.check_pool_health():
  506. log.error("数据库连接池异常,跳过本轮")
  507. return ""
  508. os.makedirs(OUT_DIR, exist_ok=True)
  509. out_file = os.path.abspath(os.path.join(OUT_DIR, f"{OUT_PREFIX}_{date.today():%Y%m%d}.xlsx"))
  510. try:
  511. build_report(log, pool, out_file)
  512. log.success(f"已售日报已生成: {out_file}")
  513. except Exception as e:
  514. log.error(f"生成已售日报失败: {e}")
  515. return ""
  516. if SEND_WECHAT:
  517. wx.send_wechat_group_file(log=log, file_path=out_file)
  518. return out_file
  519. def schedule_task():
  520. """定时入口:每天 REPORT_TIME 生成并发送已售日报。"""
  521. # run_once(logger) # 调试时取消注释立即跑一次
  522. schedule.every().day.at(REPORT_TIME).do(run_once, logger)
  523. while True:
  524. schedule.run_pending()
  525. time.sleep(1)
  526. if __name__ == "__main__":
  527. schedule_task()