فهرست منبع

feat(stats): 新增超级仓库已售统计与超新星已售每日报告

- 新增企业微信群机器人消息发送工具支持文本、markdown及文件发送
- 实现超级仓库SuperVault累计已售统计报告,支持平台和主播维度统计及明细导出
- 实现超新星SuperVault已售每日报告,统计昨天售罄成交的拼团数据
- Excel报表采用分区Sheet结构,包含汇总、主播汇总与主播组队明细
- 报告支持配置日期区间,定时任务每日自动生成并支持后续微信企业群推送
- 日志按天切分、保留7天,支持调试及异常监控
- 开发MySQL连接池辅助数据查询,统筹多表联合统计口径与格式化
- 提供工整的单位格式化和样式定义以提升Excel报表可读性和专业度
charley 1 ماه پیش
والد
کامیت
506889a555

+ 108 - 0
cxx_spider/docs/优化记录_super_vault_20260817.md

@@ -0,0 +1,108 @@
+# super_vault 采集 优化记录
+
+> 日期:2026/08/17
+> 涉及文件:`super_vault_daily_spider.py`
+> 关联表:`super_vault_product_record`
+
+## 1. 优化背景
+
+最近几天(2026/08/13–08/15)入库的数据,`completion_time`(完成时间)和 `sold_time`(售罄成交时刻)两个时间字段大量为空(NULL)。需要定位成因并判断能否补齐。
+
+### 诊断结论(两个字段成因不同)
+
+通过按 `status` 分布统计 + 逐个实测 `/app/teamup/detail` 接口,得到两条清晰规律:
+
+**① `completion_time`:平台只在团走到 `status=9`(发货完成)时才生成。**
+
+- 库中 747 条 `status=9` 的团**全部有值**;`status=8/0/10/3` 等未完成态**源头即为 None**(detail 接口实测全部返回 None)。
+- 截图中的团为 `status=8`(待发货),尚未走到完成态,平台自身还未生成 `completionTime`。属正常业务状态,**当前无法补齐**,等团发货完成、`status` 变 9 后由日常任务自动带上。
+
+**② `sold_time`:平台只在团「售罄」后才生成,且存在历史遗留缺口。**
+
+| 库中状态 | detail 现在有 soldTime | 能否补 | 原因 |
+|---|---|---|---|
+| status=8 待发货(已满员售罄) | 有 | 能补 | 列表首次入库后才售罄,旧值未回写 |
+| status=3 已推进到售罄的 | 部分有 | 能补 | 同上 |
+| status=9 早期完成团(约 570 条) | 无 | 补不了 | 平台早期无 soldTime 字段,源头即空 |
+| status=0 预售未开卖 | 无(soldCount=0) | 不需补 | 尚未售出 |
+| status=10 未售罄结束 | 无(soldCount<count) | 补不了 | 未售罄,源头无值 |
+
+**根因**:列表接口首次入库走 `INSERT IGNORE`(只增不改),团在入库后才售罄,`soldTime` 由平台事后生成,旧值不再回写;本应由 `update_stale_products` 每日刷新补齐,但常驻进程 `start_cxx_spider.py`(每天 07:01 跑 daily 任务)近两天未运行,导致漏补。
+
+## 2. 改动内容
+
+在 `super_vault_daily_spider.py` 中:
+
+1. **新增轻量专职函数 `backfill_sold_time(log, sql_pool, token)`**
+   - 候选范围:`sold_time IS NULL AND sold_count > 0 AND status IN (3, 8)`(已售罄/售后阶段、大概率能拿到 soldTime 的团)。
+   - 逐个查 `/detail`,**仅当返回 soldTime 时才 UPDATE,且只写 `sold_time` 单字段**,源头无值即跳过,不误写。
+   - 复用现有带重试的 `get_teamup_detail()`;每次请求 `sleep(0.3)` 限速。
+
+2. **挂进 `cxx_daily_main`**:在 `get_vod_list`(拉列表)之后、`update_stale_products` 之前调用,使最新入库的团优先补上 `sold_time`。
+
+> 为什么单独做而不复用 `update_stale_products`:后者是「全字段重刷」的重任务(候选上千、耗时十几分钟,且会每日白查约 570 个源头无 soldTime 的历史团)。把 `sold_time` 这一高频刚需拎成轻量步骤(仅扫几十个团、只改单字段),更快、职责单一,即使将来重任务被调整/降频,`sold_time` 仍有专职兜底。
+
+## 3. 改动前后对比
+
+### 新增函数
+
+```python
+def backfill_sold_time(log, sql_pool, token):
+    """精准补齐已售罄团缺失的 sold_time(售罄成交时刻)。"""
+    rows = sql_pool.select_all(
+        "SELECT pid FROM super_vault_product_record "
+        "WHERE sold_time IS NULL AND sold_count > 0 AND status IN (3, 8)")
+    pids = [r[0] for r in rows] if rows else []
+    log.info(f"待补 sold_time 的已售罄团 {len(pids)} 个")
+    filled = 0
+    for pid in pids:
+        try:
+            data = get_teamup_detail(log, pid, token)
+            if not data:
+                continue
+            sold_time = data.get("soldTime")
+            if not sold_time:          # 源头也无(团未真正售罄),跳过不误写
+                continue
+            sql_pool.update_one_or_dict(
+                table="super_vault_product_record",
+                data={"sold_time": sold_time},
+                condition={"pid": pid})
+            filled += 1
+        except Exception as e:
+            log.error(f"补 sold_time pid={pid} 失败: {e}")
+        time.sleep(0.3)                # 限速,避免请求过密触发风控
+    log.info(f"sold_time 补齐完成,成功回写 {filled}/{len(pids)} 个")
+```
+
+### 主流程接入(cxx_daily_main)
+
+```python
+        # 获取所有 pid
+        try:
+            get_vod_list(log, sql_pool, token[0])
+        except Exception as e:
+            log.error(f"Error fetching last_product_id: {e}")
+
+        # 精准补齐已售罄团(status 3/8)的 sold_time:轻量、只改单字段,最新入库的团先补上
+        try:
+            backfill_sold_time(log, sql_pool, token[0])
+        except Exception as e:
+            log.error(f"Error backfilling sold_time: {e}")
+
+        # 刷新未完成拼团(status!=9)的会变字段 ...
+        try:
+            update_stale_products(log, sql_pool, token[0])
+        except Exception as e:
+            log.error(f"Error updating stale products: {e}")
+```
+
+### 一次性手动补数结果
+
+对已售罄团执行精准补数:**候选 28 个,补齐 23 条**(含截图框住的 pid 2825–2837 等),5 条源头无值跳过。8/15 的 16 条 `sold_time` 已全部补齐。
+
+## 4. 注意事项
+
+1. **必须保证常驻进程运行**:新步骤与原有 `update_stale_products` 都依赖 `start_cxx_spider.py`(每天 07:01 触发 daily)。最近漏补的真正根因就是该进程未运行——重启后每日任务才会自动补 `sold_time`。
+2. **`completion_time` 当前无法补齐属正常**:需等团 `status` 变为 9(发货完成),平台才生成该字段,届时 `update_stale_products` 会自动推进 status 并带上完成时间,无需人工干预。
+3. **仍会有少量 `sold_time` 空且补不了**:`status=9` 早期完成团(约 570 条)源头本就无 `soldTime`;`status=0/10` 未售罄团同理。这些非采集遗漏,无需处理。
+4. **与 `update_stale_products` 存在轻微重叠**:`status=3/8` 团在两处都会查一次 detail,每日多约 28 次请求、耗时数秒,可忽略;换来 `sold_time` 的独立可靠保障。

+ 90 - 0
cxx_spider/docs/统计报告_超级仓库已售_20260814.md

@@ -0,0 +1,90 @@
+# 超级仓库 SuperVault · 已售统计报告文档
+
+> 参照得卡 DECA `stats/daily_report.py` 移植到新平台 super_vault,适配「自营单商家 + 主播维度」。
+> 生成日期:2026/08/14 运行环境:Python 3.12.10
+
+## 1. 目标信息
+
+- **数据源**:`super_vault_product_record`(已售拼团商品)/ `super_vault_report_record`(拆卡报告)
+- **数据库**:`crawler @ 100.64.0.25:3306`(读运行目录 `application.yml`)
+- **数据目标**:产出与得卡同款「已售统计报告」Excel(单 Sheet 分区),字段:销售额 / 成团数 / 参与人数 / 均拼单价 / 人均消费,另出每条组队明细。
+- **交付物**:
+  - `stats/super_vault_his_report.py` —— 报告脚本(累计/多口径统计报告)
+  - `stats/super_vault_stats.sql` —— 可在 Navicat 单独跑的等价 SQL
+  - `stats/超级仓库已售统计报告_20251230-20260812.xlsx` —— 本次结果
+
+## 2. 口径与字段映射(得卡 → 新平台)
+
+| 语义 | 得卡 deca | 新平台 super_vault | 说明 |
+|---|---|---|---|
+| 商品唯一 id | `product_code` | `pid` | |
+| 商家 | `merchant_user_id` / `merchant_name` | `anchor_id` / `anchor_username` | **自营,全站算一个商家**,主播仅作二级拆分 |
+| 团名 | `title` | `title` | |
+| 系列/类型 | `series_name` / `spec_name` | `type_name`(卡种) / `standard_name`(规格) | |
+| 单价 | `unit_price` | `sign_price` | |
+| 已售份数 / 总份数 | `sold_count` / `card_count` | `sold_count` / `count` | |
+| 团总额 | `team_total_amount` | `total_price` | 团成交总额 |
+| 成交时间 / 开售时间 | `completed_at` / `sale_start_at` | `completion_time` / `sold_time` / `sell_time` | 完成态才有 `completion_time`;`sold_time`(售罄成交时刻)由更新任务从 `detail.soldTime` 补采 |
+| 回放 | `replay_url` | `vod_url` | 未入表格列 |
+| 中卡用户(参与人数) | `report.hit_user_nickname` | `report.user_name` | 去重近似参团人头 |
+
+**核心口径**:
+- 统计范围 = **`sold_count>0` 且 `status NOT IN (0,1,2,3)`**:剔除未开卖(sold=0) 与 status 0/1/2/3(未开始/预售/进行中等未成交态),只留 status 4/6/7/8/9/10/12 等有效成交态。(早期误用 `status=9` 会漏掉待发货团,导致时间只到 07-26,已修正。)
+- 主播维度**按 `anchor_username` 分组**:同名不同 `anchor_id`(如两个「北京小周」)合并为同一位主播。
+- 销售额 = `SUM(sign_price * count)`(单价 × 总份数 = 满仓总价;**随机卡种/未售罄团会高于实收 `total_price`**,如 `pid=2810` 高约 70 万。如需实收改回 `COALESCE(total_price, sold_count*sign_price)`)。
+- 成团数 = 该口径下的拼团商品数;参与人数 = 拆卡报告 `user_name` 去重(平台跨全部商品去重,主播按各自商品去重,故各主播逐个相加 ≥ 平台)。
+- 均拼单价 = 销售额/成团数;人均消费 = 销售额/参与人数(中卡近似,偏高,仅供参考)。
+- 明细「参与人数(本团)」= 该团拆卡报告 `user_name` 去重(中卡近似),口径与汇总一致、拆到单团。
+- **成交时间口径** = `COALESCE(completion_time, sold_time, sell_time)`:完成时间(发货完成态才有) → 售罄成交时刻(`sold_time`) → 开售时间 依次兜底。待发货团(status=8)多无 `completion_time` 但有 `sold_time`,故成交时间/售卖时长覆盖率大幅提升(`soldTime` 实测覆盖约 98%,仅个别早期团为空)。
+
+## 3. 报告结构(单 Sheet 分区)
+
+1. **一、平台汇总(自营·全站累计)**:主播数 + 5 项指标(竖排)。
+2. **二、各主播汇总**:每主播一行,按销售额倒序。
+3. **三、各主播每条组队明细**:按主播销售额倒序分块,每块内按总金额倒序。明细列(13 列):序号 / 团名 / 编号 / 卡种 / 规格 / 单价 / 总份数 / 进度% / 总金额 / **参与人数(本团)** / 开售时间 / 成交时间 / 售卖时长。
+
+本次结果概览(口径:`sold>0` 且 `status∉{0,1,2,3}`,金额=单价×总份数;成交/开售时间 2025-12-30 ~ 2026-08-14):
+- 平台:主播数 7 · 销售额 **¥42,337,118.01** · 成团 927 · 参与 2693 人 · 均拼单价 ¥45,671 · 人均消费 ¥15,721。
+- 主播销售额排序:郑宇航(1213万) > Jerry(1183万) > 金豆豆(977万) > 刘畅(453万) > Wqi(332万) > 石头哥(72万) > 北京小周(4万)。
+
+## 4. 与得卡版的差异(因平台结构不同而砍/改)
+
+- **去掉**「商家数 / 其他商家汇总 / 重点商家」——自营单商家,改以主播为二级维度。
+- **去掉**「魔都真实买家(buy_record)」「进度里程碑(onsale 进度快照)」——新平台无这两张表,参与人数统一用拆卡报告近似。
+- **统计口径**:得卡按 `completed_at` 时间窗;新平台按 `sold_count>0 且 status NOT IN(0,1,2,3)` 筛有效成交团,销售额按 单价×总份数,主播按名字合并。
+
+## 5. 统计窗口开关
+
+脚本顶部 `WIN_MODE`:
+- `"all"`(默认):有效成交团累计(`sold_count>0` 且 `status NOT IN(0,1,2,3)`)。
+- `"daily"`:成交锚点 `COALESCE(completion_time, sold_time, sell_time) ∈ [昨天17:00, 今天03:00]`。
+- `"range"`:显式 `STAT_START ~ STAT_END`,锚点同上。
+
+## 6. 运行方式
+
+```bash
+# 从项目根目录运行(cwd=根目录,mysql_pool 读根目录 application.yml)
+cd D:\work\2026-01-28(cxx_spider)
+python stats/super_vault_his_report.py
+# 产物落在 stats/ 目录,文件名带窗口标签(如 超级仓库已售统计报告_20251230-20260812.xlsx)
+```
+
+- 定时:`schedule_task()` 每天 09:20 跑一次(当前 `__main__` 走一次性 `main()`)。
+- 企微推送:`SEND_WECHAT=False`(本项目暂无 `auto_send_wx_msg` 模块,接入后置 True 复用)。
+
+## 7. 踩坑记录
+
+- **别拿 status=9 当「已售」**:平台是「售罄成交 → 待发货 → 完成(status=9)」流程,只有走到 9 才回填 `completion_time`。用 `status=9` 会漏掉 8 月已售罄但仍在待发货态的团(时间被卡在 07-26)。本表整表即已售,全表统计即可。
+- **金额别用 `sign_price×count` 硬算**:随机卡种团每张卡价不同,`total_price` 才是精算总额;用 `COALESCE(total_price, …)` 优先取 `total_price`。
+- **口径收敛过程**:整表 1170 → 剔除 sold=0(占位/预售,195 个)→ 再剔除 status 0/1/2/3(未成交态)→ 最终 **927 个有效成交团**。金额口径也从 `COALESCE(total_price, sold*sign)` 改为 **`sign_price*count`(单价×总份数=满仓货值)**——按主公口径统计"标准货值"而非实收;随机卡种/未售罄团满仓价会高于实收 `total_price`,如需实收改回 COALESCE。
+- **completion_time 缺失靠 sold_time 补**:只有 status=9 完成团才回填 `completion_time`,大量待发货团(status=8)已售罄成交但无 `completion_time`。detail 接口的 `soldTime`(售罄成交时刻)几乎都有值(实测 50 抽 49),已在 `super_vault_daily_spider.py` 的更新任务里补采到新列 `sold_time`;报告成交时间用 `COALESCE(completion_time, sold_time, sell_time)`。注意 `soldTime` 不是 detail 顶层就一定有——早期老团(如 2025-12 的 `pid=1639`)为空,这类退回 `sell_time`。
+- **INSERT IGNORE 固化 → 加更新任务**:列表接口 `INSERT IGNORE` 只增不改,商品首次预售入库后 `sign_price/sold_count/status/completion_time/sold_time/vod_url` 等即便平台更新也不回写。`super_vault_daily_spider.py` 新增 `update_stale_products`:对 `status!=9` 或缺直播信息/`sold_time` 的团逐个查 `detail` 刷新,每日随爬虫跑。
+- **时间字段转文本再写 Excel**:`completion_time/sold_time/sell_time` 为 datetime,写入前统一 `strftime` 转字符串,避免 openpyxl 日期序列/格式错乱。
+- **合并同名主播**:`anchor_id=2` 与 `4` 都叫「北京小周」,已改按 `anchor_username` 分组合并为一位主播(注意:若不同人恰好同名会被并到一起,本数据无此情况)。
+- **Python 版本按实测写**:同项目老爬虫头部写 3.10.8 是旧值,实测环境为 3.12.10。
+
+## 8. 举一反三
+
+- 新增维度只需在 `SUMMARY_HEADERS` / `DETAIL_COLS` 加列 + 对应 SQL 取数,样式函数(`_write_summary_block` / `_write_details`)自动复用。
+- 若日后平台补上「真实买家/进度快照」表,可仿得卡魔都扩展明细(参与人数真实化 + 进度里程碑列)增强本报告。
+- 同一套骨架(连接池 + 分区 Excel + 主播/商家维度)可平移到其它自营/多商家卡牌拼团平台,仅改字段映射与统计口径即可。

+ 74 - 0
cxx_spider/docs/超新星已售每日报告_20260817.md

@@ -0,0 +1,74 @@
+# 超新星 SuperVault · 已售每日报告 文档
+
+> 生成日期:2026/08/17 运行环境:Python 3.12.10
+> 涉及:新增 `stats/super_vault_nova_daily_report.py`;旧脚本 `super_vault_daily_report.py` 改名为 `super_vault_his_report.py`
+
+## 1. 目标信息
+
+- **数据源**:`super_vault_product_record`(已售拼团商品)/ `super_vault_report_record`(拆卡报告)
+- **数据库**:`crawler @ 100.64.0.25:3306`(读运行目录 `application.yml`)
+- **数据目标**:按 `sold_time`(售罄成交时刻)统计**昨天全天**成交的拼团,产出「超新星已售每日报告」Excel(单 Sheet 分区)。
+- **交付物**:
+  - `stats/super_vault_nova_daily_report.py` —— 每日报告脚本
+  - `stats/超新星已售每日报告_{统计日}.xlsx` —— 产物(如 `超新星已售每日报告_20260816.xlsx`)
+
+## 2. 统计口径
+
+- **统计范围**:`sold_time ∈ [昨天 00:00, 今天 00:00)`,用服务器 `CURDATE() - INTERVAL 1 DAY` 与 `CURDATE()` 卡窗,文件名日期=统计日(前一天),与时间窗同源、不受本机时区影响。
+- **不再叠加 status/sold_count 过滤**:有 `sold_time` 即代表该团已售罄成交,天然满足有效成交条件。
+- **成交时间 / 售卖时长**一律以 `sold_time` 为准(售卖时长 = `sold_time − sell_time`);这是与累计报告最核心的口径差异(后者用 `COALESCE(completion_time, sold_time, sell_time)` 兜底锚点)。
+- 以下沿用累计报告口径:销售额 = `SUM(sign_price * count)`(单价 × 总份数);主播按 `anchor_username` 分组(同名不同 `anchor_id` 合并);参与人数 = 拆卡报告 `user_name` 去重(中卡近似,平台跨全部商品去重、主播按各自商品去重,故各主播相加 ≥ 平台);均拼单价 = 销售额/成团数;人均消费 = 销售额/参与人数。
+
+## 3. 报告结构(单 Sheet 分区)
+
+1. **一、平台汇总(自营·当日成交累计)**:主播数 + 5 项指标(竖排)。
+2. **二、各主播汇总**:每主播一行,按销售额倒序。
+3. **三、各主播每条组队明细**:按主播销售额倒序分块,块内按总金额倒序。明细列(13 列):序号 / 团名 / 编号 / 卡种 / 规格 / 单价 / 总份数 / 进度% / 总金额 / 参与人数(本团) / 开售时间 / 成交时间 / 售卖时长。
+
+> **实现方式**:`super_vault_nova_daily_report.py` 通过 `import super_vault_his_report as base` 复用累计报告脚本的 Excel 渲染/样式组件(`_write_summary_block` / `_write_anchor_table` / `_write_details` / 样式常量 / `_fmt_dt` 等),只重写口径相关的 `win_clause` 与 `fetch_*`。两脚本互不影响。
+
+## 4. 与累计报告(his_report)的区别
+
+| 维度 | `super_vault_his_report.py`(累计) | `super_vault_nova_daily_report.py`(每日) |
+|---|---|---|
+| 定位 | 历史 / 多口径统计报告 | 每日成交报告 |
+| 统计窗 | `all`(全表累计)/ `daily` / `range` 可切 | 固定「昨天全天」 |
+| 成交锚点 | `COALESCE(completion_time, sold_time, sell_time)` | 纯 `sold_time` |
+| 过滤 | `sold_count>0 且 status NOT IN(0,1,2,3)` | 仅 `sold_time` 落在昨天(无需再过滤) |
+| 产物前缀 | 超级仓库已售统计报告 | 超新星已售每日报告 |
+| 文件名后缀 | 时间跨度(如 `_20251230-20260814`) | 统计日(如 `_20260816`) |
+
+## 5. 运行方式
+
+```bash
+# 从项目根目录运行(cwd=根目录,mysql_pool 读根目录 application.yml)
+cd D:\work\2026-01-28(cxx_spider)
+python stats/super_vault_nova_daily_report.py
+# 产物落在 stats/ 目录:超新星已售每日报告_{昨天}.xlsx
+```
+
+- 定时:脚本内 `schedule_task()` 已设**每天 09:30** 生成前一天报告(需常驻进程;当前 `__main__` 走一次性 `main()`)。
+- 企微推送:本脚本暂未接入(可仿 `super_vault_his_report.py` 的 `SEND_WECHAT` 开关扩展)。
+
+## 6. 脚本改名说明
+
+旧脚本 `super_vault_daily_report.py` 名字带 "daily" 但实际默认是 `all` 累计口径,与新的真·每日报告放一起易混,故改名 `super_vault_his_report.py`(his = history,历史/累计)。
+
+已同步的全部引用(无遗漏):
+- `stats/super_vault_nova_daily_report.py`:`import super_vault_his_report as base`、`from super_vault_his_report import (...)`、docstring。
+- `stats/super_vault_his_report.py`:自身 docstring 的运行命令。
+- `docs/统计报告_超级仓库已售_20260814.md`:两处路径引用。
+- 清理了 `__pycache__` 下旧的 `super_vault_daily_report.*.pyc`。
+- `start_cxx_spider.py` 不依赖报告脚本,无需改动。
+
+## 7. 本次结果概览(统计日 2026-08-16)
+
+- 平台:成团 **7** · 主播 **3** · 销售额 **¥2,168,951.30** · 参与 349 人。
+- 主播销售额排序:郑宇航(3 团 / 176.1 万)> 金豆豆(2 团 / 36.4 万)> Jerry(2 团 / 4.4 万)。
+- 明细成交时间全部落在 8-16,进度 100%,售卖时长按 `sold_time − sell_time` 计。
+
+## 8. 注意事项
+
+- **数据质量依赖 `sold_time` 已采齐**:每日报告完全以 `sold_time` 为准,而该字段靠 `super_vault_daily_spider.py` 的 `backfill_sold_time` / `update_stale_products` 从 `detail.soldTime` 补采。若常驻爬虫进程 `start_cxx_spider.py` 未运行,最新团的 `sold_time` 可能滞后,导致当日成交被少统计——**务必保证爬虫常驻进程正常运行**。
+- **昨天成交团可能尚无拆卡报告**:多为 status=8 待发货、还没开播拆卡,故「参与人数」可能偏低甚至为 0,属正常(中卡近似口径)。
+- 时间字段写入 Excel 前统一 `strftime` 转文本,避免日期序列/格式错乱(复用自累计报告)。

+ 229 - 0
cxx_spider/stats/auto_send_wx_msg.py

@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/04
+"""企业微信群机器人通用发送模块(文本 / markdown_v2 / 文件)。
+
+对外三个入口:
+    - send_wechat_group_msg :发文本或 markdown_v2 文案,items 元素可为字符串或 (名称, 链接) 元组。
+    - send_wechat_group_file:发文件(先 upload_media 拿 media_id,再发 file 消息),供报表脚本发 Excel。
+
+作为工具库被其它脚本 import,不在模块级配置 loguru sink,日志默认落到调用方的 logger。
+
+变更记录:
+    2026/08/11 从 PC 版微信(wxauto)迁到企业微信群机器人;新增 send_wechat_group_file 发文件能力。
+"""
+import os
+import re
+import json
+
+import requests
+from loguru import logger
+
+# 企业微信群机器人 Webhook 地址(key 为群机器人凭证,按需替换;当前为测试群)
+# WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=b8d398e2-f27e-42ce-af78-336867460122" # 测试
+WEBHOOK_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=fc5619c2-5699-485f-bed1-a5fdc79ca513"
+# 素材上传接口:发文件/图片前先把素材传上去换 media_id(type=file/voice;文件 5B~20MB,media_id 有效期 3 天)
+UPLOAD_URL_TMPL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={key}&type={media_type}"
+
+FILE_MIN_BYTES = 5                    # 企微限制:文件不得小于 5 字节
+FILE_MAX_BYTES = 20 * 1024 * 1024     # 企微限制:文件不得大于 20MB
+
+
+def _extract_key(webhook_url: str) -> str | None:
+    """从群机器人 Webhook 地址里抽出 key(上传素材接口要单独拼 key)。
+
+    Args:
+        webhook_url (str): 形如 ...webhook/send?key=xxxx 的 Webhook 地址。
+
+    Returns:
+        str | None: 抽到的 key;地址不含 key 时返回 None。
+    """
+    m = re.search(r"key=([0-9a-fA-F\-]+)", webhook_url)
+    return m.group(1) if m else None
+
+
+def build_markdown_content(items: list, title: str) -> str:
+    """把列表拼成 markdown 文案(每条编号,条间加分割线)。
+
+    Args:
+        items (list): 元素为字符串或 (名称, 链接) 元组。
+        title (str): 文案标题(渲染为四级标题)。
+
+    Returns:
+        str: 拼好的 markdown 文本。
+    """
+    md_content = f"#### {title}\n"
+    for i, item in enumerate(items, 1):
+        if isinstance(item, tuple) and len(item) == 2:
+            name, link = item
+            md_content += f"{i}. [{name}]({link})\n"
+        elif isinstance(item, str):
+            md_content += f"{i}. {item}\n"
+        else:
+            md_content += f"{i}. {str(item)}\n"
+
+        if i < len(items):
+            md_content += "\n---\n\n"
+    return md_content
+
+
+def build_text_content(items: list) -> str:
+    """把列表拼成纯文本文案(每条编号,条间加分割线)。
+
+    Args:
+        items (list): 元素为字符串或 (名称, 链接) 元组。
+
+    Returns:
+        str: 拼好的纯文本。
+    """
+    content = ""
+    for i, item in enumerate(items, 1):
+        if isinstance(item, tuple) and len(item) == 2:
+            name, link = item
+            content += f"{i}. {name}: {link}\n"
+        elif isinstance(item, str):
+            content += f"{i}. {item}\n"
+        else:
+            content += f"{i}. {str(item)}\n"
+
+        if i < len(items):
+            content += "----------------------------------\n"
+    return content
+
+
+def send_wechat_group_msg(log=None, items=None, mentioned_list=None,
+                          msg_type="markdown", title="🚀 提醒通知") -> dict | None:
+    """发送文本 / markdown_v2 消息到企业微信群机器人。
+
+    Args:
+        log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
+        items (list, optional): 消息条目,元素为字符串或 (名称, 链接) 元组。Defaults to None。
+        mentioned_list (list, optional): text 类型下 @ 的成员手机号/@all 列表。Defaults to None。
+        msg_type (str, optional): 消息类型 text / markdown。Defaults to "markdown"。
+        title (str, optional): markdown 文案标题。Defaults to "🚀 提醒通知"。
+
+    Returns:
+        dict | None: 企微返回的 JSON;发送失败返回 None。
+    """
+    if items is None:
+        items = []
+    if log is None:
+        log = logger
+
+    headers = {"Content-Type": "application/json"}
+    if msg_type == "text":
+        data = {
+            "msgtype": "text",
+            "text": {
+                "content": build_text_content(items),
+                "mentioned_list": mentioned_list if mentioned_list else [],
+            },
+        }
+    else:  # 默认 markdown_v2
+        data = {
+            "msgtype": "markdown_v2",
+            "markdown_v2": {"content": build_markdown_content(items, title)},
+        }
+
+    try:
+        log.info(f"正在发送企微消息: {title}")
+        resp = requests.post(WEBHOOK_URL, headers=headers,
+                             data=json.dumps(data, ensure_ascii=False).encode("utf-8"),
+                             timeout=(5, 30))
+        resp.raise_for_status()
+        result = resp.json()
+        if result.get("errcode") not in (0, None):  # 企微业务错误码非 0 也算失败
+            log.error(f"企微消息发送失败: {result}")
+            return None
+        log.success("企微消息发送成功")
+        return result
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微消息发送失败: {e}")
+        return None
+
+
+def _upload_media(log, file_path: str, media_type: str = "file") -> str | None:
+    """把本地文件上传到企微群机器人素材接口,换取 media_id。
+
+    Args:
+        log (loguru.Logger): 日志对象。
+        file_path (str): 本地文件绝对/相对路径。
+        media_type (str, optional): 素材类型 file / voice。Defaults to "file"。
+
+    Returns:
+        str | None: 上传成功返回 media_id(有效期 3 天);文件不存在/超限/上传失败返回 None。
+    """
+    key = _extract_key(WEBHOOK_URL)
+    if not key:
+        log.error("Webhook 地址里没解析到 key,无法上传素材")
+        return None
+
+    size = os.path.getsize(file_path)
+    if not (FILE_MIN_BYTES <= size <= FILE_MAX_BYTES):  # 企微限制 5B~20MB
+        log.error(f"文件大小 {size} 字节超出企微限制(5B~20MB):{file_path}")
+        return None
+
+    url = UPLOAD_URL_TMPL.format(key=key, media_type=media_type)
+    try:
+        with open(file_path, "rb") as f:
+            # 素材字段名必须为 media,且要带文件名(群里展示的就是这个名字)
+            files = {"media": (os.path.basename(file_path), f, "application/octet-stream")}
+            resp = requests.post(url, files=files, timeout=(5, 60))
+        resp.raise_for_status()
+        result = resp.json()
+        if result.get("errcode") != 0:
+            log.error(f"企微素材上传失败: {result}")
+            return None
+        return result.get("media_id")
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微素材上传异常: {e}")
+        return None
+
+
+def send_wechat_group_file(log=None, file_path: str = None) -> dict | None:
+    """发送一个本地文件到企业微信群机器人(自动先上传素材换 media_id 再发 file 消息)。
+
+    Args:
+        log (loguru.Logger, optional): 日志对象;不传用全局 logger。Defaults to None。
+        file_path (str, optional): 待发送文件路径(如 Excel 报表)。Defaults to None。
+
+    Returns:
+        dict | None: 企微返回的 JSON;文件不存在/上传失败/发送失败返回 None。
+    """
+    if log is None:
+        log = logger
+    if not file_path or not os.path.isfile(file_path):
+        log.error(f"待发送文件不存在: {file_path}")
+        return None
+
+    media_id = _upload_media(log, file_path, "file")
+    if not media_id:
+        return None
+
+    data = {"msgtype": "file", "file": {"media_id": media_id}}
+    try:
+        log.info(f"正在发送企微文件: {os.path.basename(file_path)}")
+        resp = requests.post(WEBHOOK_URL, headers={"Content-Type": "application/json"},
+                             data=json.dumps(data).encode("utf-8"), timeout=(5, 30))
+        resp.raise_for_status()
+        result = resp.json()
+        if result.get("errcode") != 0:
+            log.error(f"企微文件发送失败: {result}")
+            return None
+        log.success(f"企微文件发送成功: {os.path.basename(file_path)}")
+        return result
+    except requests.exceptions.RequestException as e:
+        log.error(f"企微文件发送失败: {e}")
+        return None
+
+
+if __name__ == "__main__":
+    # 自测:发送一条示例 markdown 消息
+    import sys
+    logger.remove()
+    logger.add(sys.stderr, level="INFO")
+    send_wechat_group_msg(
+        items=["示例商品A(单价¥199,100份)", "示例商品B(单价¥299,50份)"],
+        title="🧪 企微机器人自测",
+    )

+ 537 - 0
cxx_spider/stats/super_vault_his_report.py

@@ -0,0 +1,537 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/14
+"""超级仓库 SuperVault · 已售统计报告(Excel 单 Sheet 分区)。
+
+平台为自营(全站视为一个商家),故没有「商家维度」,改以「主播」为二级拆分维度。
+一个拼团商品 = 一个「组队」,故「每条明细」= 每个已售拼团商品。
+
+报告结构(自上而下单 Sheet 分区):
+    一、平台汇总(整个自营平台累计)
+    二、各主播汇总(每主播一行,按销售额倒序)
+    三、各主播每条组队明细(按主播销售额倒序分块,每块内按总金额倒序)
+
+口径说明:
+    - 统计范围:默认 WIN_MODE="all",剔除 sold_count=0(未开卖)与 status 0/1/2/3(未开始/预售/
+      进行中等未成交态),只统计 status 4/6/7/8/9/10/12 等有效成交态的团。
+    - 主播维度按 anchor_username 分组:同名不同 anchor_id(如两个「北京小周」)合并为同一位主播。
+    - 销售额 = SUM(sign_price * count)(单价 × 总份数 = 满仓总价;
+              注意随机卡种/未售罄团会高于实收 total_price)。
+    - 成团数 = 该范围内的拼团商品数。
+    - 参与人数 = super_vault_report_record.user_name 去重(拆卡报告中卡用户,近似参团人头;
+              仅覆盖有拆卡报告的商品,非真实买家,偏低)。平台口径跨全部商品去重;各主播口径按
+              该主播商品单独去重,故各主播逐个相加(人次) ≥ 平台。
+    - 均拼单价 = 销售额 / 成团数
+    - 人均消费 = 销售额 / 参与人数(按中卡近似,偏高,仅供参考)
+    - 明细「参与人数(本团)」= 该团拆卡报告 user_name 去重(中卡近似),口径与汇总一致、拆到单团。
+    - 本平台无 buy_record(真实买家)与 onsale 进度快照表,故无「真实参与人数」与「进度里程碑」列。
+
+从项目根目录运行:python stats/super_vault_his_report.py(cwd=根目录,读根目录 application.yml)
+"""
+import os
+import sys
+import time
+# 把项目根目录加入 import 路径(与得卡报告一致:根目录留一份公共模块时可直接 import)
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+from datetime import date, datetime
+
+import schedule
+from loguru import logger
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+from mysql_pool import MySQLConnectionPool
+
+# 日志:按天切分文件,保留 7 天(常驻定时运行)
+logger.remove()
+logger.add("./logs/super_vault_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")
+
+# 企微发送:本项目暂无 auto_send_wx_msg 模块,默认关闭;日后接入企微机器人时置 True 并放置发送模块即可
+SEND_WECHAT = False
+
+OUT_PREFIX = "超级仓库已售统计报告"    # 输出文件名前缀,后缀加窗口标签
+
+# 统计窗口模式:
+#   "all"   —— 全表已售商品累计,默认口径(不按 status/售罄过滤)
+#   "daily" —— 成交锚点 COALESCE(completion_time, sell_time) 落在 [昨天17:00, 今天03:00]
+#   "range" —— 显式区间 STAT_START ~ STAT_END(含两端),锚点同上
+WIN_MODE = "all"
+STAT_START = "2026-08-01 00:00:00"   # WIN_MODE="range" 时生效
+STAT_END = "2026-09-01 00:00:00"     # WIN_MODE="range" 时生效
+
+# 成交时间锚点:完成时间(completion_time,发货完成态才有) → 售罄成交时刻(sold_time,近期团几乎都有) →
+# 开售时间(sell_time) 依次兜底。多数待发货团无 completion_time 但有 sold_time,故成交时间覆盖率大幅提升。
+TS_ANCHOR = "COALESCE({a}.completion_time, {a}.sold_time, {a}.sell_time)"
+
+# 明细表列规格:(表头, 明细dict取值键, 是否千分位金额格式);取值键为「序号」时按行号自增填充。
+# 「参与人数(本团)」= 该团拆卡报告 user_name 去重(中卡近似),放在总金额后、开售时间前。
+DETAIL_COLS = [
+    ("序号", "序号", False), ("团名(商品标题)", "团名", False), ("编号", "编号", False),
+    ("卡种", "卡种", False), ("规格", "规格", False), ("单价", "单价", True),
+    ("总份数", "总份数", False), ("进度%", "进度", False), ("总金额", "总金额", True),
+    ("参与人数(本团)", "参与人数", False),
+    ("开售时间", "开售时间", False), ("成交时间", "成交时间", False), ("售卖时长", "售卖时长", False),
+]
+# 分区标题/大标题横向着色跨列数:取明细列数,保证蓝条覆盖整表宽
+SECTION_SPAN = len(DETAIL_COLS)
+# 汇总指标(平台/主播通用)
+SUMMARY_HEADERS = ["销售额", "成团数", "参与人数", "均拼单价", "人均消费"]
+
+# ---- 样式 ----
+FONT_TITLE = Font(bold=True, size=14)
+FONT_SECTION = Font(bold=True, size=12, color="FFFFFF")
+FONT_HEADER = Font(bold=True)
+FILL_SECTION = PatternFill("solid", fgColor="4472C4")   # 分区标题:蓝底白字
+FILL_HEADER = PatternFill("solid", fgColor="D9E1F2")     # 表头:浅蓝底
+FILL_SUBTOTAL = PatternFill("solid", fgColor="FCE4D6")   # 汇总行:浅橙底
+THIN = Side(style="thin", color="BFBFBF")
+BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
+MONEY_FMT = "#,##0.00"
+ALIGN_LEFT = Alignment(horizontal="left", vertical="center")   # 数值左对齐,避免宽列里右飘留大空白
+
+
+def win_clause(alias: str) -> str:
+    """按 WIN_MODE 生成某表别名的 WHERE 片段(供主查询与参与人数子查询共用)。
+
+    统一先剔除 sold_count=0 的未开卖占位/预售团;默认 "all" 仅此一条过滤,
+    daily/range 再叠加成交锚点 COALESCE(completion_time, sell_time) 卡时间窗
+    (range 常量边界直接内联,STAT_START/STAT_END 为本文件常量、非外部输入,无注入风险)。
+
+    Args:
+        alias (str): 商品表别名(如 "p" / "pp")。
+
+    Returns:
+        str: WHERE 片段。
+    """
+    # 剔除未开卖(sold=0) 与 status 0/1/2/3(未开始/预售/进行中等未成交态),只留 4/6/7/8/9/10/12 等有效成交态
+    base = f"{alias}.sold_count > 0 AND {alias}.status NOT IN (0, 1, 2, 3)"
+    ts = TS_ANCHOR.format(a=alias)
+    if WIN_MODE == "daily":
+        return (f"{base} AND {ts} >= (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR"
+                f" AND {ts} <= CURDATE() + INTERVAL 3 HOUR")
+    if WIN_MODE == "range":
+        return f"{base} AND {ts} >= '{STAT_START}' AND {ts} <= '{STAT_END}'"
+    return base
+
+
+def get_window_label(pool) -> tuple[str, str]:
+    """取当前统计窗口的展示文案与文件名标签。
+
+    all 模式取全表成交锚点的时间跨度(排除 sell_time 未来占位脏数据,避免时间轴被污染)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[str, str]: (标题展示文案, 文件名日期标签)。
+    """
+    if WIN_MODE == "daily":
+        row = pool.select_all(
+            "SELECT (CURDATE() - INTERVAL 1 DAY) + INTERVAL 17 HOUR, CURDATE() + INTERVAL 3 HOUR")[0]
+        return f"每日成交窗 {row[0]} ~ {row[1]}", f"{date.today():%Y%m%d}"
+    if WIN_MODE == "range":
+        return f"成交区间 {STAT_START} ~ {STAT_END}", \
+               f"{STAT_START[:10].replace('-', '')}-{STAT_END[:10].replace('-', '')}"
+    # all:全表时间跨度(与统计口径一致:sold>0 且 status NOT IN(0,1,2,3);锚点 completion→sold→sell)
+    row = pool.select_all(
+        "SELECT MIN(COALESCE(completion_time, sold_time, sell_time)), "
+        "MAX(COALESCE(completion_time, sold_time, sell_time)) "
+        "FROM super_vault_product_record "
+        "WHERE sold_count > 0 AND status NOT IN (0, 1, 2, 3) "
+        "AND (completion_time IS NOT NULL OR sold_time IS NOT NULL OR sell_time <= NOW())")[0]
+    lo, hi = row[0], row[1]
+    tag = f"{_fmt_dt(lo, '%Y%m%d') or 'NA'}-{_fmt_dt(hi, '%Y%m%d') or 'NA'}"
+    return f"已售累计 成交/开售时间 {_fmt_dt(lo) or '-'} ~ {_fmt_dt(hi) or '-'}", tag
+
+
+def fetch_platform_summary(pool) -> dict:
+    """统计平台大盘汇总(窗口内全部已售商品)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        dict: 含 主播数/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        ROUND(SUM(p.sign_price * p.count), 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
+    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})
+
+
+def fetch_anchor_summaries(pool) -> list[dict]:
+    """统计各主播汇总(窗口内),每主播一行,按销售额倒序。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        list[dict]: 每元素含 主播名/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        p.anchor_username                                                  AS aname,
+        ROUND(SUM(p.sign_price * p.count), 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
+    FROM super_vault_product_record p
+    WHERE {win_clause('p')}
+    GROUP BY p.anchor_username
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql) or []
+    result = []
+    for aname, amount, groups, people in rows:
+        d = _pack_summary(amount, groups, people)
+        d["主播名"] = aname or "(未知主播)"
+        result.append(d)
+    return result
+
+
+def fetch_anchor_details(pool, aname: str) -> list[dict]:
+    """取单个主播窗口内每个已售拼团(组队)的明细,按总金额倒序。
+
+    按 anchor_username 取数:同名不同 anchor_id 的团合并归入同一主播。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        aname (str): 主播名 anchor_username。
+
+    Returns:
+        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 去重(中卡近似,仅覆盖有报告的团)
+        (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,
+        p.sell_time,
+        COALESCE(p.completion_time, p.sold_time)                         AS deal_time,
+        TIMESTAMPDIFF(SECOND, p.sell_time, COALESCE(p.completion_time, p.sold_time)) AS duration_secs
+    FROM super_vault_product_record p
+    WHERE p.anchor_username = %s AND {win_clause('p')}
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (aname,)) or []
+    result = []
+    for (title, serial, type_name, standard, price, sold, total, amount, 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,   # 本团拆卡报告 user_name 去重(中卡近似)
+            # 成交时间 = 完成时间(发货完成)优先,缺则用售罄成交时刻 sold_time;售卖时长=成交-开售
+            "开售时间": _fmt_dt(sell_time), "成交时间": _fmt_dt(deal_time),
+            "售卖时长": _fmt_duration(duration_secs),
+        })
+    return result
+
+
+def _pack_summary(amount, groups, people, extra: dict = None) -> dict:
+    """把销售额/成团数/参与人数打包成统一汇总 dict,并算均拼单价、人均消费。
+
+    Args:
+        amount (Decimal | None): 销售额。
+        groups (int | None): 成团数。
+        people (int | None): 参与人数。
+        extra (dict, optional): 额外要合并的字段(如主播数)。Defaults to None。
+
+    Returns:
+        dict: 含 销售额/成团数/参与人数/均拼单价/人均消费(+extra)。
+    """
+    amount = float(amount) if amount is not None else 0.0
+    groups = groups or 0
+    people = people or 0
+    avg_group = round(amount / groups, 2) if groups else None   # 均拼单价=销售额/成团数
+    avg_person = round(amount / people, 2) if people else None  # 人均消费=销售额/参与人数
+    d = {"销售额": round(amount, 2), "成团数": groups, "参与人数": people,
+         "均拼单价": avg_group, "人均消费": avg_person}
+    if extra:
+        d.update(extra)
+    return d
+
+
+def _fmt_dt(dt, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None:
+    """把 datetime/str 时间格式化为字符串(写入 Excel 前统一转文本,避免日期序列/格式错乱)。
+
+    Args:
+        dt (datetime | str | None): 时间值。
+        fmt (str, optional): 目标格式。Defaults to "%Y-%m-%d %H:%M:%S"。
+
+    Returns:
+        str | None: 格式化字符串;为空时返回 None。
+    """
+    if dt is None or dt == "":
+        return None
+    if isinstance(dt, str):
+        try:
+            dt = datetime.strptime(dt, "%Y-%m-%d %H:%M:%S")
+        except Exception:
+            return dt  # 非标准字符串原样返回
+    return dt.strftime(fmt)
+
+
+def _fmt_duration(secs) -> str | None:
+    """把售卖时长(秒)格式化为可读中文字符串。
+
+    差值口径 = 成交完成时间 completion_time − 开售时间 sell_time,反映该团多快卖完/售罄。
+
+    Args:
+        secs (int | None): 成交与开售之差(秒);None 或负数(时间缺失/异常)视为无效。
+
+    Returns:
+        str | None: 形如 "2小时13分" / "45分07秒" / "38秒";无效时返回 None(单元格留空)。
+    """
+    if secs is None or secs < 0:
+        return None
+    secs = int(secs)
+    h, rem = divmod(secs, 3600)
+    m, s = divmod(rem, 60)
+    if h:
+        return f"{h}小时{m}分"
+    if m:
+        return f"{m}分{s:02d}秒"
+    return f"{s}秒"
+
+
+def _style_row(ws, row_idx: int, ncol: int, font=None, fill=None, border=True):
+    """给某一行的前 ncol 个单元格套字体/填充/边框。
+
+    Args:
+        ws: openpyxl worksheet。
+        row_idx (int): 行号(1-based)。
+        ncol (int): 要处理的列数(从第 1 列起)。
+        font (Font, optional): 字体。Defaults to None。
+        fill (PatternFill, optional): 填充。Defaults to None。
+        border (bool, optional): 是否加边框。Defaults to True。
+    """
+    for c in range(1, ncol + 1):
+        cell = ws.cell(row=row_idx, column=c)
+        if font:
+            cell.font = font
+        if fill:
+            cell.fill = fill
+        if border:
+            cell.border = BORDER
+
+
+def _write_summary_block(ws, r: int, headers: list, data: dict) -> int:
+    """在第 r 行起竖排写汇总(每指标一行:A列指标名 + B列数值),返回下一空闲行。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        headers (list[str]): 汇总指标名列表。
+        data (dict): 汇总数据 dict。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    for h in headers:
+        label = ws.cell(row=r, column=1, value=h)      # A 列:指标名(加粗浅蓝)
+        label.font = FONT_HEADER
+        label.fill = FILL_HEADER
+        label.border = BORDER
+        val = ws.cell(row=r, column=2, value=data.get(h))  # B 列:数值(浅橙、左对齐)
+        if h in ("销售额", "均拼单价", "人均消费"):
+            val.number_format = MONEY_FMT
+        val.fill = FILL_SUBTOTAL
+        val.alignment = ALIGN_LEFT
+        val.border = BORDER
+        r += 1
+    return r
+
+
+def _write_section_title(ws, r: int, text: str) -> int:
+    """在第 r 行写一行分区大标题(蓝底白字,横向铺满整表宽)。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        text (str): 标题文字。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    ws.cell(row=r, column=1, value=text)
+    _style_row(ws, r, SECTION_SPAN, font=FONT_SECTION, fill=FILL_SECTION, border=False)
+    return r + 1
+
+
+def _write_anchor_table(ws, r: int, anchors: list) -> int:
+    """在第 r 行起横排写各主播汇总表(表头 + 每主播一行),返回下一空闲行。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        anchors (list[dict]): 各主播汇总列表(fetch_anchor_summaries 的返回)。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    headers = ["主播"] + SUMMARY_HEADERS
+    for i, h in enumerate(headers):
+        ws.cell(row=r, column=1 + i, value=h)
+    _style_row(ws, r, len(headers), font=FONT_HEADER, fill=FILL_HEADER)
+    r += 1
+    for a in anchors:
+        vals = [a["主播名"], a["销售额"], a["成团数"], a["参与人数"], a["均拼单价"], a["人均消费"]]
+        for i, v in enumerate(vals):
+            cell = ws.cell(row=r, column=1 + i, value=v)
+            if headers[i] in ("销售额", "均拼单价", "人均消费"):
+                cell.number_format = MONEY_FMT
+            cell.alignment = ALIGN_LEFT
+        _style_row(ws, r, len(headers))
+        r += 1
+    return r
+
+
+def _write_details(ws, r: int, details: list, cols: list) -> int:
+    """在第 r 行起写一个主播的明细表(表头 + 每条组队明细)。
+
+    Args:
+        ws: openpyxl worksheet。
+        r (int): 起始行号。
+        details (list[dict]): 明细数据(fetch_anchor_details 的返回)。
+        cols (list[tuple]): 列规格列表,每项 (表头, 取值键, 是否金额格式);取值键为「序号」时按行号自增填充。
+
+    Returns:
+        int: 下一个空闲行号。
+    """
+    for i, (header, _key, _money) in enumerate(cols):
+        ws.cell(row=r, column=1 + i, value=header)
+    _style_row(ws, r, len(cols), font=FONT_HEADER, fill=FILL_HEADER)
+    r += 1
+
+    if not details:
+        ws.cell(row=r, column=1, value="(该窗口无成交)")
+        return r + 1
+
+    for idx, d in enumerate(details, 1):
+        for i, (_header, key, is_money) in enumerate(cols):
+            value = idx if key == "序号" else d.get(key)   # 「序号」列用行号,其余按键取值
+            cell = ws.cell(row=r, column=1 + i, value=value)
+            if is_money:
+                cell.number_format = MONEY_FMT
+        _style_row(ws, r, len(cols))
+        r += 1
+    return r
+
+
+def build_report(pool, out: str):
+    """汇总各段数据并生成单 Sheet 分区 Excel 报告。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+    """
+    win_label, _ = get_window_label(pool)
+    platform = fetch_platform_summary(pool)
+    anchors = fetch_anchor_summaries(pool)
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "已售统计报告"
+    r = 1  # 显式行游标(避免依赖 ws.max_row 忽略空行的问题)
+
+    # 报告大标题
+    ws.cell(row=r, column=1, value=f"超级仓库 SuperVault · 已售统计报告 ({win_label})")
+    _style_row(ws, r, SECTION_SPAN, font=FONT_TITLE, border=False)
+    r += 2  # 空一行
+
+    # 一、平台汇总(自营,全站视为一个商家;主播为二级拆分维度)
+    r = _write_section_title(ws, r, "一、平台汇总(自营·全站累计)")
+    r = _write_summary_block(ws, r, ["主播数"] + SUMMARY_HEADERS, platform)
+    r += 1
+
+    # 二、各主播汇总
+    r = _write_section_title(ws, r, f"二、各主播汇总(共 {len(anchors)} 位,按销售额倒序)")
+    r = _write_anchor_table(ws, r, anchors)
+    r += 2
+
+    # 三、各主播每条组队明细(按主播销售额倒序分块)
+    r = _write_section_title(ws, r, "三、各主播每条组队明细(按总金额倒序)")
+    for a in anchors:
+        details = fetch_anchor_details(pool, a["主播名"])
+        ws.cell(row=r, column=1,
+                value=(f" ▼ {a['主播名']} 销售额 {a['销售额']:,.2f} / 成团 {a['成团数']} / "
+                       f"参与 {a['参与人数']}(共 {len(details)} 条明细)"))
+        _style_row(ws, r, SECTION_SPAN, font=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]
+    for i, w in enumerate(widths):
+        ws.column_dimensions[chr(65 + i)].width = w
+    ws.freeze_panes = "A2"  # 冻结标题行
+
+    wb.save(out)
+
+
+def run_once(log) -> str:
+    """连库生成已售统计报告(可选发送到微信群)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
+    """
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return ""
+    # 文件名带窗口标签,避免覆盖;绝对路径不受运行目录影响
+    _, tag = get_window_label(pool)
+    out_dir = os.path.dirname(os.path.abspath(__file__))   # 输出到本脚本所在 stats 目录
+    out_file = os.path.join(out_dir, f"{OUT_PREFIX}_{tag}.xlsx")
+    build_report(pool, out_file)
+    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}")
+    return out_file
+
+
+def main():
+    """命令行一次性生成(手动/调试用)。"""
+    run_once(logger)
+
+
+def schedule_task():
+    """定时入口:每天 09:20 生成已售报告(供日后接入企微/每日推送后使用)。"""
+    # run_once(logger)  # 立即跑一次(调试时取消注释)
+    schedule.every().day.at("09:20").do(run_once, logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    main()

+ 280 - 0
cxx_spider/stats/super_vault_nova_daily_report.py

@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+# Author : Charley
+# Python : 3.12.10
+# Date   : 2026/08/17
+"""超新星 SuperVault · 已售每日报告(严格按 sold_time 统计「昨天」成交的拼团)。
+
+与同目录累计报告 super_vault_his_report.py 共用 Excel 渲染/样式组件,仅统计口径不同:
+
+    - 统计范围:售罄成交时刻 sold_time 落在「昨天」[00:00, 次日00:00) 的拼团(服务器 CURDATE 口径)。
+      有 sold_time 即代表该团已售罄成交,故不再叠加 status/sold_count 过滤。
+    - 成交时间/售卖时长一律以 sold_time 为准(累计报告用的是 completion_time→sold_time 兜底锚点)。
+
+报告结构(自上而下单 Sheet 分区,与累计报告一致):
+    一、平台汇总(当日成交累计)
+    二、各主播汇总(每主播一行,按销售额倒序)
+    三、各主播每条组队明细(按主播销售额倒序分块,块内按总金额倒序)
+
+从项目根目录运行:python stats/super_vault_nova_daily_report.py(cwd=根目录,读根目录 application.yml)
+"""
+import os
+import sys
+import time
+# 把项目根目录加入 import 路径(读根目录 application.yml、复用根目录公共模块)
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import schedule
+from loguru import logger
+from openpyxl import Workbook
+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,
+    _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")
+
+OUT_PREFIX = "超新星已售每日报告"    # 输出文件名前缀,后缀加统计日期
+
+# 企微发送开关:默认关闭;接入企微机器人后置 True 即启用(发送模块 auto_send_wx_msg.py 在同目录)
+SEND_WECHAT = False
+
+
+def win_clause(alias: str) -> str:
+    """生成「昨天成交」窗口的 WHERE 片段:sold_time ∈ [昨天0点, 今天0点)。
+
+    供主查询与参与人数子查询共用。有 sold_time 即已售罄成交,无需再过滤 status/sold_count。
+
+    Args:
+        alias (str): 商品表别名(如 "p" / "pp")。
+
+    Returns:
+        str: WHERE 片段。
+    """
+    return (f"{alias}.sold_time >= CURDATE() - INTERVAL 1 DAY "
+            f"AND {alias}.sold_time < CURDATE()")
+
+
+def window_label(pool) -> tuple[str, str]:
+    """取「昨天」的标题展示文案与文件名日期标签。
+
+    直接用服务器 CURDATE()-1,保证与 win_clause 的时间窗完全同源、不受本机时区影响。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        tuple[str, str]: (标题文案, 文件名日期标签 YYYYMMDD)。
+    """
+    y = pool.select_all("SELECT CURDATE() - INTERVAL 1 DAY")[0][0]  # date 对象
+    return f"每日成交(按售罄时刻 sold_time){y} 全天", f"{y:%Y%m%d}"
+
+
+def fetch_platform_summary(pool) -> dict:
+    """统计平台大盘汇总(昨天成交的全部拼团)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        dict: 含 主播数/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        ROUND(SUM(p.sign_price * p.count), 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
+    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})
+
+
+def fetch_anchor_summaries(pool) -> list[dict]:
+    """统计各主播汇总(昨天成交),每主播一行,按销售额倒序。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+
+    Returns:
+        list[dict]: 每元素含 主播名/销售额/成团数/参与人数/均拼单价/人均消费。
+    """
+    sql = f"""
+    SELECT
+        p.anchor_username                                                  AS aname,
+        ROUND(SUM(p.sign_price * p.count), 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
+    FROM super_vault_product_record p
+    WHERE {win_clause('p')}
+    GROUP BY p.anchor_username
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql) or []
+    result = []
+    for aname, amount, groups, people in rows:
+        d = _pack_summary(amount, groups, people)
+        d["主播名"] = aname or "(未知主播)"
+        result.append(d)
+    return result
+
+
+def fetch_anchor_details(pool, aname: str) -> list[dict]:
+    """取单个主播昨天成交的每个拼团(组队)明细,按总金额倒序。
+
+    成交时间与售卖时长均以 sold_time 为准(售卖时长 = sold_time − sell_time)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        aname (str): 主播名 anchor_username。
+
+    Returns:
+        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)
+        (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,
+        p.sell_time,
+        p.sold_time                                                      AS deal_time,
+        TIMESTAMPDIFF(SECOND, p.sell_time, p.sold_time)                  AS duration_secs
+    FROM super_vault_product_record p
+    WHERE p.anchor_username = %s AND {win_clause('p')}
+    ORDER BY amount DESC
+    """
+    rows = pool.select_all(sql, (aname,)) or []
+    result = []
+    for (title, serial, type_name, standard, price, sold, total, amount, 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,
+            # 成交时间 = 售罄成交时刻 sold_time;售卖时长 = sold_time − 开售时间
+            "开售时间": _fmt_dt(sell_time), "成交时间": _fmt_dt(deal_time),
+            "售卖时长": _fmt_duration(duration_secs),
+        })
+    return result
+
+
+def build_report(pool, out: str):
+    """汇总各段数据并生成单 Sheet 分区 Excel(超新星·已售每日报告)。
+
+    Args:
+        pool (MySQLConnectionPool): MySQL 连接池。
+        out (str): 导出的 xlsx 路径。
+    """
+    win_label, _ = window_label(pool)
+    platform = fetch_platform_summary(pool)
+    anchors = fetch_anchor_summaries(pool)
+
+    wb = Workbook()
+    ws = wb.active
+    ws.title = "已售每日报告"
+    r = 1  # 显式行游标
+
+    # 报告大标题
+    ws.cell(row=r, column=1, value=f"超新星 SuperVault · 已售每日报告 ({win_label})")
+    _style_row(ws, r, SECTION_SPAN, font=FONT_TITLE, border=False)
+    r += 2
+
+    # 一、平台汇总
+    r = _write_section_title(ws, r, "一、平台汇总(自营·当日成交累计)")
+    r = _write_summary_block(ws, r, ["主播数"] + SUMMARY_HEADERS, platform)
+    r += 1
+
+    # 二、各主播汇总
+    r = _write_section_title(ws, r, f"二、各主播汇总(共 {len(anchors)} 位,按销售额倒序)")
+    r = _write_anchor_table(ws, r, anchors)
+    r += 2
+
+    # 三、各主播每条组队明细
+    r = _write_section_title(ws, r, "三、各主播每条组队明细(按总金额倒序)")
+    if not anchors:
+        ws.cell(row=r, column=1, value="(昨天无按 sold_time 成交的拼团)")
+    for a in anchors:
+        details = fetch_anchor_details(pool, a["主播名"])
+        ws.cell(row=r, column=1,
+                value=(f" ▼ {a['主播名']} 销售额 {a['销售额']:,.2f} / 成团 {a['成团数']} / "
+                       f"参与 {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]
+    for i, w in enumerate(widths):
+        ws.column_dimensions[chr(65 + i)].width = w
+    ws.freeze_panes = "A2"  # 冻结标题行
+
+    wb.save(out)
+
+
+def run_once(log) -> str:
+    """连库生成超新星·已售每日报告(统计昨天成交)。
+
+    Args:
+        log: 日志对象。
+
+    Returns:
+        str: 生成的 xlsx 绝对路径;数据库连接池异常时返回空串。
+    """
+    pool = MySQLConnectionPool(log=log)
+    if not pool.check_pool_health():
+        log.error("数据库连接池异常")
+        return ""
+    _, tag = window_label(pool)              # 文件名日期用「昨天」,与统计窗同源
+    out_dir = os.path.dirname(os.path.abspath(__file__))   # 输出到本脚本所在 stats 目录
+    out_file = os.path.join(out_dir, f"{OUT_PREFIX}_{tag}.xlsx")
+    build_report(pool, out_file)
+    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}")
+    return out_file
+
+
+def main():
+    """命令行一次性生成(手动/调试用)。"""
+    run_once(logger)
+
+
+def schedule_task():
+    """定时入口:每天 09:30 生成前一天的已售每日报告。"""
+    # run_once(logger)  # 立即跑一次(调试时取消注释)
+    schedule.every().day.at("09:30").do(run_once, logger)
+    while True:
+        schedule.run_pending()
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    main()

+ 122 - 0
cxx_spider/stats/super_vault_stats.sql

@@ -0,0 +1,122 @@
+-- ============================================================
+-- 超级仓库 SuperVault · 已售统计 SQL(自营·全站累计口径)
+-- 日期:2026/08/14
+-- 数据源:super_vault_product_record / super_vault_report_record
+-- 统计口径:
+--   0) 统计范围 = sold_count>0 且 status NOT IN (0,1,2,3)(剔除未开卖与未开始/预售/进行中等未成交态,
+--      只留 status 4/6/7/8/9/10/12 等有效成交态)。
+--      —— 平台为自营,全站视为一个商家;用「主播 anchor」作二级拆分维度。
+--      —— 主播按 anchor_username 分组:同名不同 anchor_id(如两个「北京小周」)合并为一位主播。
+--   1) 销售额 = SUM(sign_price * count)  —— 单价×总份数=满仓总价(随机卡种/未售罄团会高于实收 total_price)
+--   2) 成团数 = 拼团商品数(sold_count>0 且 status NOT IN(0,1,2,3))
+--   3) 参与人数 = 拆卡报告 user_name 去重(中卡用户近似,仅覆盖有报告的商品)
+--   4) 均拼单价 = 销售额 / 成团数
+--   5) 人均消费 = 销售额 / 参与人数
+--   6) 成交时间 = COALESCE(completion_time, sold_time, sell_time):完成→售罄成交→开售 依次兜底
+--   —— 若只统计某段时间:给主查询与子查询都加成交锚点过滤,例如
+--      AND COALESCE(p.completion_time, p.sold_time, p.sell_time)
+--          BETWEEN '2026-08-01 00:00:00' AND '2026-09-01 00:00:00'
+--      (两段 SELECT 各自独立,可在 Navicat 单独选中执行)
+-- ============================================================
+
+
+-- ------------------------------------------------------------
+-- 一、平台大盘(自营·全站累计)
+-- ------------------------------------------------------------
+SELECT
+    -- 销售额(元)= 单价×总份数
+    ROUND(SUM(p.sign_price * p.count), 2)                                        AS 销售额,
+
+    -- 主播数(按主播名去重,合并同名)
+    COUNT(DISTINCT p.anchor_username)                                            AS 主播数,
+
+    -- 成团数
+    COUNT(*)                                                                     AS 成团数,
+
+    -- 均拼单价(销售额 / 成团数)
+    ROUND(SUM(p.sign_price * p.count) / NULLIF(COUNT(*), 0), 2)                  AS 均拼单价,
+
+    -- 参与人数(拆卡报告中卡用户昵称去重)
+    (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.sold_count > 0 AND pp.status NOT IN (0, 1, 2, 3)
+        AND rr.user_name IS NOT NULL AND rr.user_name <> '')                     AS 参与人数,
+
+    -- 人均消费(销售额 / 参与人数)
+    ROUND(
+        SUM(p.sign_price * p.count) /
+        NULLIF((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.sold_count > 0 AND pp.status NOT IN (0, 1, 2, 3)
+                   AND rr.user_name IS NOT NULL AND rr.user_name <> ''), 0),
+        2
+    )                                                                            AS 人均消费
+FROM super_vault_product_record p
+WHERE p.sold_count > 0 AND p.status NOT IN (0, 1, 2, 3);
+
+
+-- ------------------------------------------------------------
+-- 二、各主播汇总(按主播名分组·合并同名,按销售额倒序)
+-- ------------------------------------------------------------
+SELECT
+    p.anchor_username                                                            AS 主播名,
+
+    -- 销售额 = 单价×总份数
+    ROUND(SUM(p.sign_price * p.count), 2)                                        AS 销售额,
+
+    -- 成团数
+    COUNT(*)                                                                     AS 成团数,
+
+    -- 参与人数(该主播商品拆卡报告中卡用户去重)
+    (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 pp.sold_count > 0 AND pp.status NOT IN (0, 1, 2, 3)
+        AND rr.user_name IS NOT NULL AND rr.user_name <> '')                    AS 参与人数,
+
+    -- 均拼单价
+    ROUND(SUM(p.sign_price * p.count) / NULLIF(COUNT(*), 0), 2)                  AS 均拼单价,
+
+    -- 人均消费
+    ROUND(
+        SUM(p.sign_price * p.count) /
+        NULLIF((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 pp.sold_count > 0 AND pp.status NOT IN (0, 1, 2, 3)
+                   AND rr.user_name IS NOT NULL AND rr.user_name <> ''), 0),
+        2
+    )                                                                            AS 人均消费
+FROM super_vault_product_record p
+WHERE p.sold_count > 0 AND p.status NOT IN (0, 1, 2, 3)
+GROUP BY p.anchor_username
+ORDER BY 销售额 DESC;
+
+
+-- ------------------------------------------------------------
+-- 三、单个主播每条组队明细(把主播名换成具体主播,如 '郑宇航',按总金额倒序)
+--   参与人数(本团) = 该团拆卡报告 user_name 去重
+-- ------------------------------------------------------------
+SELECT
+    p.title                                                                     AS 团名,
+    p.serial                                                                     AS 编号,
+    p.type_name                                                                 AS 卡种,
+    p.standard_name                                                             AS 规格,
+    p.sign_price                                                                AS 单价,
+    p.count                                                                     AS 总份数,
+    ROUND(p.sold_count / NULLIF(p.count, 0) * 100, 1)                           AS 进度百分比,
+    ROUND(p.sign_price * p.count, 2)                                            AS 总金额,
+    (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 参与人数,
+    p.sell_time                                                                 AS 开售时间,
+    -- 成交时间 = 完成时间(发货完成态才有) → 售罄成交时刻 sold_time → 依次兜底
+    COALESCE(p.completion_time, p.sold_time)                                    AS 成交时间,
+    TIMESTAMPDIFF(SECOND, p.sell_time, COALESCE(p.completion_time, p.sold_time)) AS 售卖时长秒
+FROM super_vault_product_record p
+WHERE p.sold_count > 0 AND p.status NOT IN (0, 1, 2, 3)
+  AND p.anchor_username = '郑宇航'   -- ← 改这里选主播
+ORDER BY 总金额 DESC;