| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- # -*- coding: utf-8 -*-
- # Author : Charley
- # Python : 3.12.10
- # Date : 2026/08/19
- """集物星球在售日报:只读 jw_onsale_product_record / jw_shop_record / jw_onsale_daily_record,生成 Excel 发企微群。
- 逻辑对齐参考项目 deca 的在售报表:只读库不触发抓取(抓取由 jw_onsale_spider 独立定时落库)。
- 每天 09/15/20/01 四档各生成一份带小时的独立文件(每档=当刻实时在售快照,互不覆盖)。
- Sheet:概览 / 今日新增商家 / 商品明细(今日新上架行淡红高亮)/ 在售趋势(按每日快照差分,最近4天)。
- 金额字段库中已是元(入库时已换算),直接展示。
- """
- import os
- import sys
- import time
- from datetime import datetime, timedelta
- import schedule
- from loguru import logger
- from mysql_pool import MySQLConnectionPool
- import auto_send_wx_msg as wx
- import jw_report_excel as xl
- PRODUCT_TYPE_NAME = {"1": "福袋", "2": "变风盒", "3": "错版卡", "4": "原盒"} # 商品类型码→名
- OUT_DIR = "./reports" # 报表输出目录(留档不删)
- OUT_PREFIX = "集物星球在售日报" # 文件名前缀
- SEND_WECHAT = True # 是否发企微
- REPORT_TIMES = ("09:00", "15:00", "20:00", "01:00") # 四档生成时间(上午/下午/晚上/凌晨场),对齐 deca 在售采集
- TREND_DAYS = 4 # 在售趋势展示天数(今日+前3日)
- SHOP_LIMIT = 100 # 「其他商家」sheet 存量商家展示上限
- LISTING_HOUR_DAYS = 7 # 「上架时段分布」统计近 N 天
- 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")
- def yuan(raw) -> float | None:
- """把库中金额(已是元, DECIMAL)转 float 供 Excel。
- Args:
- raw: 库中金额值(Decimal/数值/None)。
- Returns:
- float | None: 元;空值返回 None。
- """
- return float(raw) if raw is not None else None
- def fmt_dt(v) -> str:
- """把时间字段格式化为 "YYYY-MM-DD HH:MM" 字符串。
- Args:
- v (datetime | str | None): 时间值。
- Returns:
- str: 格式化字符串;空值返回空串。
- """
- if not v:
- return ""
- if isinstance(v, datetime):
- return v.strftime("%Y-%m-%d %H:%M")
- return str(v)
- def build_overview(wb, pool) -> None:
- """写「概览」sheet:商家/在售/今日新增等 KPI 竖排。
- Args:
- wb: 工作簿。
- pool: 数据库连接池。
- """
- shop_total = pool.select_all("SELECT COUNT(*) FROM jw_shop_record")[0][0]
- onsale_total = pool.select_all("SELECT COUNT(*) FROM jw_onsale_product_record WHERE is_on_sale=1")[0][0]
- new_shops = pool.select_all("SELECT COUNT(*) FROM jw_shop_record WHERE DATE(gmt_create_time)=CURDATE()")[0][0]
- new_goods = pool.select_all(
- "SELECT COUNT(*) FROM jw_onsale_product_record WHERE is_on_sale=1 AND DATE(sold_time)=CURDATE()")[0][0]
- sold_total = pool.select_all( # 在售商品已售份数合计(sold_count=stock-residue),对齐 deca「今日累计已售(份)」
- "SELECT COALESCE(SUM(sold_count),0) FROM jw_onsale_product_record WHERE is_on_sale=1")[0][0]
- ws = xl.add_sheet(wb, "概览")
- xl.write_table(ws, 1, ["指标", "数值"], [
- ("商家总数", shop_total),
- ("在售商品总数", onsale_total),
- ("今日新增商家", new_shops),
- ("今日新增商品", new_goods),
- ("今日累计已售(份)", int(sold_total or 0)),
- ("生成时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
- ], ["text", "int"], freeze=False)
- def build_new_shops(wb, pool) -> None:
- """写「今日新增商家」sheet:当天入库的商家一行一条。
- Args:
- wb: 工作簿。
- pool: 数据库连接池。
- """
- rows = pool.select_all(
- "SELECT s.corp_name, s.fans_amount, "
- "SUM(CASE WHEN p.is_on_sale=1 THEN 1 ELSE 0 END) AS onsale_cnt, "
- "SUM(CASE WHEN DATE(p.sold_time)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
- "FROM jw_shop_record s LEFT JOIN jw_onsale_product_record p ON p.corp_info_id = s.corp_info_id "
- "WHERE DATE(s.gmt_create_time)=CURDATE() "
- "GROUP BY s.corp_info_id, s.corp_name, s.fans_amount "
- "ORDER BY onsale_cnt DESC, s.fans_amount DESC") or []
- ws = xl.add_sheet(wb, "今日新增商家")
- xl.write_table(ws, 1, ["商家", "粉丝数", "在售商品数", "今日新上架"],
- [(r[0], r[1], int(r[2] or 0), int(r[3] or 0)) for r in rows],
- ["text", "int", "int", "int"])
- def build_other_shops(wb, pool) -> None:
- """写「其他商家」sheet:非今日新增的存量商家(在售数倒序),一行一条。
- 对齐 deca:商家表 LEFT JOIN 在售商品表,取 `gmt_create_time != CURDATE()` 的存量商家,
- 与「今日新增商家」sheet 互补。
- Args:
- wb: 工作簿。
- pool: 数据库连接池。
- """
- rows = pool.select_all(
- "SELECT s.corp_name, s.fans_amount, "
- "SUM(CASE WHEN p.is_on_sale=1 THEN 1 ELSE 0 END) AS onsale_cnt, "
- "SUM(CASE WHEN DATE(p.sold_time)=CURDATE() AND p.is_on_sale=1 THEN 1 ELSE 0 END) AS today_new "
- "FROM jw_shop_record s LEFT JOIN jw_onsale_product_record p ON p.corp_info_id = s.corp_info_id "
- "WHERE DATE(s.gmt_create_time) != CURDATE() "
- "GROUP BY s.corp_info_id, s.corp_name, s.fans_amount "
- "ORDER BY onsale_cnt DESC, s.fans_amount DESC LIMIT %s", (SHOP_LIMIT,)) or []
- ws = xl.add_sheet(wb, "其他商家")
- xl.write_table(ws, 1, ["商家", "粉丝数", "在售商品数", "今日新上架"],
- [(r[0], r[1], int(r[2] or 0), int(r[3] or 0)) for r in rows],
- ["text", "int", "int", "int"])
- def build_products(wb, pool) -> None:
- """写「商品明细」sheet:全部在售商品,今日新上架行淡红高亮。
- Args:
- wb: 工作簿。
- pool: 数据库连接池。
- """
- recs = pool.select_all(
- "SELECT corp_info_name, goods_name, gift_series, product_type, specification_name, sold_time, "
- "amount, stock_amount, residue_stock_amount, (DATE(sold_time)=CURDATE()) AS is_new "
- "FROM jw_onsale_product_record WHERE is_on_sale=1 ORDER BY corp_info_name, is_new DESC, goods_id")
- headers = ["商家", "商品名", "系列", "类型", "规格", "上架时间", "单价(元)",
- "已售数", "总份数", "进度", "已售总价(元)", "新品"]
- col_types = ["text", "text", "text", "text", "text", "text", "money",
- "int", "int", "pct", "money", "text"]
- rows, new_flags = [], []
- for (corp, name, ip, ptype, spec, sold_time, amount, stock, residue, is_new) in recs:
- sold = (stock - residue) if isinstance(stock, int) and isinstance(residue, int) else None
- progress = (sold / stock) if isinstance(sold, int) and isinstance(stock, int) and stock > 0 else 0
- gmv = (sold * float(amount)) if isinstance(sold, int) and amount is not None else None # amount 已是元(Decimal)
- rows.append((corp, name, ip, PRODUCT_TYPE_NAME.get(str(ptype), ptype), spec, fmt_dt(sold_time),
- yuan(amount), sold, stock, progress, gmv, "🆕" if is_new else ""))
- new_flags.append(bool(is_new))
- ws = xl.add_sheet(wb, "商品明细")
- xl.write_table(ws, 1, headers, rows, col_types, new_flags=new_flags)
- def fetch_onsale_trend(pool, days: int) -> list:
- """按每日快照差分出在售趋势(对齐 deca:集合差集算净新增)。
- 查最近 days 天(WHERE 多覆盖一天做最早展示日的差分基线)的快照,按 snapshot_date 聚合成
- 商家集合/商品集合;每天净新增 = 当日集合 - 前一日集合(集合差集,非数值相减)。
- Args:
- pool: 数据库连接池。
- days (int): 展示天数(今日 + 前 days-1 日)。
- Returns:
- list: 按日期倒序(最近在前)的 dict 列表,每项含
- {日期, 在售商家数, 在售拼团数, 新增商家数, 新增拼团数};最早一天无基线时新增为 None。
- """
- rows = pool.select_all(
- "SELECT snapshot_date, corp_info_id, goods_id FROM jw_onsale_daily_record "
- "WHERE snapshot_date >= CURDATE() - INTERVAL %s DAY", (days,)) or []
- day_shops, day_goods = {}, {}
- for snap_date, cid, gid in rows:
- day_shops.setdefault(snap_date, set()).add(cid)
- day_goods.setdefault(snap_date, set()).add(gid)
- dates = sorted(day_shops.keys(), reverse=True) # 新→旧
- result = []
- for snap_date in dates[:days]:
- prev = snap_date - timedelta(days=1) # 前一日基线
- shops, goods = day_shops[snap_date], day_goods[snap_date]
- if prev in day_shops:
- new_shops = len(shops - day_shops[prev]) # 净新增商家=当日有、前日无
- new_goods = len(goods - day_goods[prev]) # 净新增拼团=当日有、前日无
- else:
- new_shops = new_goods = None # 无基线,诚实留空
- result.append({"日期": str(snap_date), "在售商家数": len(shops), "在售拼团数": len(goods),
- "新增商家数": new_shops, "新增拼团数": new_goods})
- return result
- def build_trend(wb, trend: list) -> None:
- """写「在售趋势」sheet:最近 TREND_DAYS 天在售商家/拼团数 + 差分净新增(日期倒序)。
- Args:
- wb: 工作簿。
- trend (list): fetch_onsale_trend 结果。
- """
- headers = ["日期", "在售商家数", "在售拼团数", "新增商家数(差分)", "新增拼团数(差分)"]
- col_types = ["text", "int", "int", "int", "int"]
- data = [(t["日期"], t["在售商家数"], t["在售拼团数"], t["新增商家数"], t["新增拼团数"]) for t in trend]
- ws = xl.add_sheet(wb, "在售趋势")
- xl.write_table(ws, 1, headers, data, col_types)
- def _bar(value: int, vmax: int, width: int = 20) -> str:
- """按占最大值比例生成 █ 条形迷你图字符串。
- Args:
- value (int): 当前值。
- vmax (int): 最大值。
- width (int, optional): 满格字符数。Defaults to 20。
- Returns:
- str: █ 组成的条形;value/vmax 为空返回空串。
- """
- if not vmax or not value:
- return ""
- return "█" * max(1, round(value / vmax * width))
- def build_listing_hour_dist(wb, pool) -> None:
- """写「上架时段分布」sheet:近 LISTING_HOUR_DAYS 天按上架小时(0~23)统计上架商品数。
- 对齐 deca:按 `HOUR(sold_time)`(上架/开售时间) 分 24 桶,第三列为 █ 条形分布图。
- Args:
- wb: 工作簿。
- pool: 数据库连接池。
- """
- since = (datetime.now() - timedelta(days=LISTING_HOUR_DAYS)).strftime("%Y-%m-%d")
- dist = [0] * 24
- for h, c in pool.select_all(
- "SELECT HOUR(sold_time) h, COUNT(*) c FROM jw_onsale_product_record "
- "WHERE sold_time IS NOT NULL AND sold_time >= %s GROUP BY h", (since,)) or []:
- if h is not None and 0 <= int(h) < 24:
- dist[int(h)] = int(c)
- vmax = max(dist) if dist else 0
- data = [(f"{h:02d}时", dist[h], _bar(dist[h], vmax)) for h in range(24)]
- ws = xl.add_sheet(wb, "上架时段分布")
- xl.write_table(ws, 1, ["时段", "上架数", f"分布(近{LISTING_HOUR_DAYS}日累计)"], data,
- ["text", "int", "text"])
- def build_report(pool, out_file: str) -> None:
- """汇总各 sheet 生成在售日报 Excel(6 sheet,对齐 deca)。
- Args:
- pool: 数据库连接池。
- out_file (str): 输出文件路径。
- """
- wb = xl.new_workbook()
- build_overview(wb, pool) # 1 概览
- build_new_shops(wb, pool) # 2 今日新增商家
- build_other_shops(wb, pool) # 3 其他商家(存量)
- build_products(wb, pool) # 4 商品明细
- build_trend(wb, fetch_onsale_trend(pool, TREND_DAYS)) # 5 在售趋势(快照差分)
- build_listing_hour_dist(wb, pool) # 6 上架时段分布
- xl.save(wb, out_file)
- def run_once(log) -> str:
- """生成一次在售日报并发企微(只读库;DB 异常则跳过本轮)。
- Args:
- log: 日志对象。
- Returns:
- str: 生成的 Excel 路径;失败返回空串。
- """
- log.info("开始生成在售日报" + "." * 30)
- pool = MySQLConnectionPool(log=log)
- if not pool.check_pool_health():
- log.error("数据库连接池异常,跳过本轮")
- return ""
- os.makedirs(OUT_DIR, exist_ok=True)
- out_file = os.path.abspath(os.path.join(OUT_DIR, f"{OUT_PREFIX}_{datetime.now():%Y%m%d_%H时}.xlsx"))
- try:
- build_report(pool, out_file)
- log.success(f"在售日报已生成: {out_file}")
- except Exception as e:
- log.error(f"生成在售日报失败: {e}")
- return ""
- if SEND_WECHAT:
- wx.send_wechat_group_file(log=log, file_path=out_file)
- return out_file
- def schedule_task():
- """定时入口:每天 09:00 / 15:00 / 20:00 / 01:00 各生成并发送一次在售日报(四档,各出一份带小时的文件)。"""
- # run_once(logger) # 调试时取消注释立即跑一次
- for _hhmm in REPORT_TIMES:
- schedule.every().day.at(_hhmm).do(run_once, logger)
- while True:
- schedule.run_pending()
- time.sleep(1)
- if __name__ == "__main__":
- schedule_task()
|