jw_onsale_report.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/19
  5. """集物星球在售日报:只读 jw_onsale_product_record / jw_shop_record / jw_onsale_daily_record,生成 Excel 发企微群。
  6. 逻辑对齐参考项目 deca 的在售报表:只读库不触发抓取(抓取由 jw_onsale_spider 独立定时落库)。
  7. 每天 09/15/20/01 四档各生成一份带小时的独立文件(每档=当刻实时在售快照,互不覆盖)。
  8. Sheet:概览 / 今日新增商家 / 商品明细(今日新上架行淡红高亮)/ 在售趋势(按每日快照差分,最近4天)。
  9. 金额字段库中已是元(入库时已换算),直接展示。
  10. """
  11. import os
  12. import sys
  13. import time
  14. from datetime import datetime, timedelta
  15. import schedule
  16. from loguru import logger
  17. from mysql_pool import MySQLConnectionPool
  18. import auto_send_wx_msg as wx
  19. import jw_report_excel as xl
  20. PRODUCT_TYPE_NAME = {"1": "福袋", "2": "变风盒", "3": "错版卡", "4": "原盒"} # 商品类型码→名
  21. OUT_DIR = "./reports" # 报表输出目录(留档不删)
  22. OUT_PREFIX = "集物星球在售日报" # 文件名前缀
  23. SEND_WECHAT = True # 是否发企微
  24. REPORT_TIMES = ("09:00", "15:00", "20:00", "01:00") # 四档生成时间(上午/下午/晚上/凌晨场),对齐 deca 在售采集
  25. TREND_DAYS = 4 # 在售趋势展示天数(今日+前3日)
  26. SHOP_LIMIT = 100 # 「其他商家」sheet 存量商家展示上限
  27. LISTING_HOUR_DAYS = 7 # 「上架时段分布」统计近 N 天
  28. logger.remove()
  29. logger.add("./logs/onsale_report_{time:YYYYMMDD}.log", encoding="utf-8", rotation="00:00",
  30. format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {level} {message}", level="DEBUG", retention="7 day")
  31. def yuan(raw) -> float | None:
  32. """把库中金额(已是元, DECIMAL)转 float 供 Excel。
  33. Args:
  34. raw: 库中金额值(Decimal/数值/None)。
  35. Returns:
  36. float | None: 元;空值返回 None。
  37. """
  38. return float(raw) if raw is not None else None
  39. def fmt_dt(v) -> str:
  40. """把时间字段格式化为 "YYYY-MM-DD HH:MM" 字符串。
  41. Args:
  42. v (datetime | str | None): 时间值。
  43. Returns:
  44. str: 格式化字符串;空值返回空串。
  45. """
  46. if not v:
  47. return ""
  48. if isinstance(v, datetime):
  49. return v.strftime("%Y-%m-%d %H:%M")
  50. return str(v)
  51. def build_overview(wb, pool) -> None:
  52. """写「概览」sheet:商家/在售/今日新增等 KPI 竖排。
  53. Args:
  54. wb: 工作簿。
  55. pool: 数据库连接池。
  56. """
  57. shop_total = pool.select_all("SELECT COUNT(*) FROM jw_shop_record")[0][0]
  58. onsale_total = pool.select_all("SELECT COUNT(*) FROM jw_onsale_product_record WHERE is_on_sale=1")[0][0]
  59. new_shops = pool.select_all("SELECT COUNT(*) FROM jw_shop_record WHERE DATE(gmt_create_time)=CURDATE()")[0][0]
  60. new_goods = pool.select_all(
  61. "SELECT COUNT(*) FROM jw_onsale_product_record WHERE is_on_sale=1 AND DATE(sold_time)=CURDATE()")[0][0]
  62. sold_total = pool.select_all( # 在售商品已售份数合计(sold_count=stock-residue),对齐 deca「今日累计已售(份)」
  63. "SELECT COALESCE(SUM(sold_count),0) FROM jw_onsale_product_record WHERE is_on_sale=1")[0][0]
  64. ws = xl.add_sheet(wb, "概览")
  65. xl.write_table(ws, 1, ["指标", "数值"], [
  66. ("商家总数", shop_total),
  67. ("在售商品总数", onsale_total),
  68. ("今日新增商家", new_shops),
  69. ("今日新增商品", new_goods),
  70. ("今日累计已售(份)", int(sold_total or 0)),
  71. ("生成时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
  72. ], ["text", "int"], freeze=False)
  73. def build_new_shops(wb, pool) -> None:
  74. """写「今日新增商家」sheet:当天入库的商家一行一条。
  75. Args:
  76. wb: 工作簿。
  77. pool: 数据库连接池。
  78. """
  79. rows = pool.select_all(
  80. "SELECT s.corp_name, s.fans_amount, "
  81. "SUM(CASE WHEN p.is_on_sale=1 THEN 1 ELSE 0 END) AS onsale_cnt, "
  82. "SUM(CASE WHEN DATE(p.sold_time)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
  83. "FROM jw_shop_record s LEFT JOIN jw_onsale_product_record p ON p.corp_info_id = s.corp_info_id "
  84. "WHERE DATE(s.gmt_create_time)=CURDATE() "
  85. "GROUP BY s.corp_info_id, s.corp_name, s.fans_amount "
  86. "ORDER BY onsale_cnt DESC, s.fans_amount DESC") or []
  87. ws = xl.add_sheet(wb, "今日新增商家")
  88. xl.write_table(ws, 1, ["商家", "粉丝数", "在售商品数", "今日新上架"],
  89. [(r[0], r[1], int(r[2] or 0), int(r[3] or 0)) for r in rows],
  90. ["text", "int", "int", "int"])
  91. def build_other_shops(wb, pool) -> None:
  92. """写「其他商家」sheet:非今日新增的存量商家(在售数倒序),一行一条。
  93. 对齐 deca:商家表 LEFT JOIN 在售商品表,取 `gmt_create_time != CURDATE()` 的存量商家,
  94. 与「今日新增商家」sheet 互补。
  95. Args:
  96. wb: 工作簿。
  97. pool: 数据库连接池。
  98. """
  99. rows = pool.select_all(
  100. "SELECT s.corp_name, s.fans_amount, "
  101. "SUM(CASE WHEN p.is_on_sale=1 THEN 1 ELSE 0 END) AS onsale_cnt, "
  102. "SUM(CASE WHEN DATE(p.sold_time)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
  103. "FROM jw_shop_record s LEFT JOIN jw_onsale_product_record p ON p.corp_info_id = s.corp_info_id "
  104. "WHERE DATE(s.gmt_create_time) != CURDATE() "
  105. "GROUP BY s.corp_info_id, s.corp_name, s.fans_amount "
  106. "ORDER BY onsale_cnt DESC, s.fans_amount DESC LIMIT %s", (SHOP_LIMIT,)) or []
  107. ws = xl.add_sheet(wb, "其他商家")
  108. xl.write_table(ws, 1, ["商家", "粉丝数", "在售商品数", "今日新上架"],
  109. [(r[0], r[1], int(r[2] or 0), int(r[3] or 0)) for r in rows],
  110. ["text", "int", "int", "int"])
  111. def build_products(wb, pool) -> None:
  112. """写「商品明细」sheet:全部在售商品,今日新上架行淡红高亮。
  113. Args:
  114. wb: 工作簿。
  115. pool: 数据库连接池。
  116. """
  117. recs = pool.select_all(
  118. "SELECT corp_info_name, goods_name, gift_series, product_type, specification_name, sold_time, "
  119. "amount, stock_amount, residue_stock_amount, (DATE(sold_time)=CURDATE()) AS is_new "
  120. "FROM jw_onsale_product_record WHERE is_on_sale=1 ORDER BY corp_info_name, is_new DESC, goods_id")
  121. headers = ["商家", "商品名", "系列", "类型", "规格", "上架时间", "单价(元)",
  122. "已售数", "总份数", "进度", "已售总价(元)", "新品"]
  123. col_types = ["text", "text", "text", "text", "text", "text", "money",
  124. "int", "int", "pct", "money", "text"]
  125. rows, new_flags = [], []
  126. for (corp, name, ip, ptype, spec, sold_time, amount, stock, residue, is_new) in recs:
  127. sold = (stock - residue) if isinstance(stock, int) and isinstance(residue, int) else None
  128. progress = (sold / stock) if isinstance(sold, int) and isinstance(stock, int) and stock > 0 else 0
  129. gmv = (sold * float(amount)) if isinstance(sold, int) and amount is not None else None # amount 已是元(Decimal)
  130. rows.append((corp, name, ip, PRODUCT_TYPE_NAME.get(str(ptype), ptype), spec, fmt_dt(sold_time),
  131. yuan(amount), sold, stock, progress, gmv, "🆕" if is_new else ""))
  132. new_flags.append(bool(is_new))
  133. ws = xl.add_sheet(wb, "商品明细")
  134. xl.write_table(ws, 1, headers, rows, col_types, new_flags=new_flags)
  135. def fetch_onsale_trend(pool, days: int) -> list:
  136. """按每日快照差分出在售趋势(对齐 deca:集合差集算净新增)。
  137. 查最近 days 天(WHERE 多覆盖一天做最早展示日的差分基线)的快照,按 snapshot_date 聚合成
  138. 商家集合/商品集合;每天净新增 = 当日集合 - 前一日集合(集合差集,非数值相减)。
  139. Args:
  140. pool: 数据库连接池。
  141. days (int): 展示天数(今日 + 前 days-1 日)。
  142. Returns:
  143. list: 按日期倒序(最近在前)的 dict 列表,每项含
  144. {日期, 在售商家数, 在售拼团数, 新增商家数, 新增拼团数};最早一天无基线时新增为 None。
  145. """
  146. rows = pool.select_all(
  147. "SELECT snapshot_date, corp_info_id, goods_id FROM jw_onsale_daily_record "
  148. "WHERE snapshot_date >= CURDATE() - INTERVAL %s DAY", (days,)) or []
  149. day_shops, day_goods = {}, {}
  150. for snap_date, cid, gid in rows:
  151. day_shops.setdefault(snap_date, set()).add(cid)
  152. day_goods.setdefault(snap_date, set()).add(gid)
  153. dates = sorted(day_shops.keys(), reverse=True) # 新→旧
  154. result = []
  155. for snap_date in dates[:days]:
  156. prev = snap_date - timedelta(days=1) # 前一日基线
  157. shops, goods = day_shops[snap_date], day_goods[snap_date]
  158. if prev in day_shops:
  159. new_shops = len(shops - day_shops[prev]) # 净新增商家=当日有、前日无
  160. new_goods = len(goods - day_goods[prev]) # 净新增拼团=当日有、前日无
  161. else:
  162. new_shops = new_goods = None # 无基线,诚实留空
  163. result.append({"日期": str(snap_date), "在售商家数": len(shops), "在售拼团数": len(goods),
  164. "新增商家数": new_shops, "新增拼团数": new_goods})
  165. return result
  166. def build_trend(wb, trend: list) -> None:
  167. """写「在售趋势」sheet:最近 TREND_DAYS 天在售商家/拼团数 + 差分净新增(日期倒序)。
  168. Args:
  169. wb: 工作簿。
  170. trend (list): fetch_onsale_trend 结果。
  171. """
  172. headers = ["日期", "在售商家数", "在售拼团数", "新增商家数(差分)", "新增拼团数(差分)"]
  173. col_types = ["text", "int", "int", "int", "int"]
  174. data = [(t["日期"], t["在售商家数"], t["在售拼团数"], t["新增商家数"], t["新增拼团数"]) for t in trend]
  175. ws = xl.add_sheet(wb, "在售趋势")
  176. xl.write_table(ws, 1, headers, data, col_types)
  177. def _bar(value: int, vmax: int, width: int = 20) -> str:
  178. """按占最大值比例生成 █ 条形迷你图字符串。
  179. Args:
  180. value (int): 当前值。
  181. vmax (int): 最大值。
  182. width (int, optional): 满格字符数。Defaults to 20。
  183. Returns:
  184. str: █ 组成的条形;value/vmax 为空返回空串。
  185. """
  186. if not vmax or not value:
  187. return ""
  188. return "█" * max(1, round(value / vmax * width))
  189. def build_listing_hour_dist(wb, pool) -> None:
  190. """写「上架时段分布」sheet:近 LISTING_HOUR_DAYS 天按上架小时(0~23)统计上架商品数。
  191. 对齐 deca:按 `HOUR(sold_time)`(上架/开售时间) 分 24 桶,第三列为 █ 条形分布图。
  192. Args:
  193. wb: 工作簿。
  194. pool: 数据库连接池。
  195. """
  196. since = (datetime.now() - timedelta(days=LISTING_HOUR_DAYS)).strftime("%Y-%m-%d")
  197. dist = [0] * 24
  198. for h, c in pool.select_all(
  199. "SELECT HOUR(sold_time) h, COUNT(*) c FROM jw_onsale_product_record "
  200. "WHERE sold_time IS NOT NULL AND sold_time >= %s GROUP BY h", (since,)) or []:
  201. if h is not None and 0 <= int(h) < 24:
  202. dist[int(h)] = int(c)
  203. vmax = max(dist) if dist else 0
  204. data = [(f"{h:02d}时", dist[h], _bar(dist[h], vmax)) for h in range(24)]
  205. ws = xl.add_sheet(wb, "上架时段分布")
  206. xl.write_table(ws, 1, ["时段", "上架数", f"分布(近{LISTING_HOUR_DAYS}日累计)"], data,
  207. ["text", "int", "text"])
  208. def build_report(pool, out_file: str) -> None:
  209. """汇总各 sheet 生成在售日报 Excel(6 sheet,对齐 deca)。
  210. Args:
  211. pool: 数据库连接池。
  212. out_file (str): 输出文件路径。
  213. """
  214. wb = xl.new_workbook()
  215. build_overview(wb, pool) # 1 概览
  216. build_new_shops(wb, pool) # 2 今日新增商家
  217. build_other_shops(wb, pool) # 3 其他商家(存量)
  218. build_products(wb, pool) # 4 商品明细
  219. build_trend(wb, fetch_onsale_trend(pool, TREND_DAYS)) # 5 在售趋势(快照差分)
  220. build_listing_hour_dist(wb, pool) # 6 上架时段分布
  221. xl.save(wb, out_file)
  222. def run_once(log) -> str:
  223. """生成一次在售日报并发企微(只读库;DB 异常则跳过本轮)。
  224. Args:
  225. log: 日志对象。
  226. Returns:
  227. str: 生成的 Excel 路径;失败返回空串。
  228. """
  229. log.info("开始生成在售日报" + "." * 30)
  230. pool = MySQLConnectionPool(log=log)
  231. if not pool.check_pool_health():
  232. log.error("数据库连接池异常,跳过本轮")
  233. return ""
  234. os.makedirs(OUT_DIR, exist_ok=True)
  235. out_file = os.path.abspath(os.path.join(OUT_DIR, f"{OUT_PREFIX}_{datetime.now():%Y%m%d_%H时}.xlsx"))
  236. try:
  237. build_report(pool, out_file)
  238. log.success(f"在售日报已生成: {out_file}")
  239. except Exception as e:
  240. log.error(f"生成在售日报失败: {e}")
  241. return ""
  242. if SEND_WECHAT:
  243. wx.send_wechat_group_file(log=log, file_path=out_file)
  244. return out_file
  245. def schedule_task():
  246. """定时入口:每天 09:00 / 15:00 / 20:00 / 01:00 各生成并发送一次在售日报(四档,各出一份带小时的文件)。"""
  247. # run_once(logger) # 调试时取消注释立即跑一次
  248. for _hhmm in REPORT_TIMES:
  249. schedule.every().day.at(_hhmm).do(run_once, logger)
  250. while True:
  251. schedule.run_pending()
  252. time.sleep(1)
  253. if __name__ == "__main__":
  254. schedule_task()