From 968286d9c900edcb1b9a3505fa6871f8f7d12b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=BA=AA?= Date: Thu, 7 May 2026 08:00:01 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20=E6=AF=8F=E6=97=A5=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=A4=87=E4=BB=BD=20-=202026-05-07=2008:00:01?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vala_skill_hashes | 2 +- memory/.dreams/short-term-recall.json | 37 +++ memory/2026-05-06-1025.md | 5 + memory/2026-05-06-order-amortization-sql.md | 53 +++ memory/2026-05-06.md | 36 ++ output/l2_pass_time_full.txt | 245 ++++++++++++++ scripts/l2_pass_time.py | 84 +++++ scripts/l2_pass_time.sh | 48 +++ scripts/l2_pass_time.sql | 89 +++++ scripts/l2_pass_time_v2.sh | 27 ++ scripts/lead_user_analysis.py | 268 +++++++++++++++ scripts/monthly_channel_order_report.py | 308 ++++++++++++++++++ skills/vala-order-amortization-stat/SKILL.md | 2 +- skills/vala-order-amortization-stat/run.py | 9 + .../sql/detail.sql | 67 +++- .../sql/prepaid.sql | 148 +++++++++ .../sql/summary.sql | 210 ++++++------ 17 files changed, 1526 insertions(+), 112 deletions(-) create mode 100644 memory/.dreams/short-term-recall.json create mode 100644 memory/2026-05-06-1025.md create mode 100644 memory/2026-05-06-order-amortization-sql.md create mode 100644 memory/2026-05-06.md create mode 100644 output/l2_pass_time_full.txt create mode 100644 scripts/l2_pass_time.py create mode 100755 scripts/l2_pass_time.sh create mode 100644 scripts/l2_pass_time.sql create mode 100644 scripts/l2_pass_time_v2.sh create mode 100644 scripts/lead_user_analysis.py create mode 100644 scripts/monthly_channel_order_report.py create mode 100644 skills/vala-order-amortization-stat/sql/prepaid.sql diff --git a/.vala_skill_hashes b/.vala_skill_hashes index cd8318e..41727ce 100644 --- a/.vala_skill_hashes +++ b/.vala_skill_hashes @@ -12,4 +12,4 @@ vala-component-practice-stat 8e768e2641019d27bd41f4647d2d90f24182a0554dad5ad9f41 cron-schedule e103cbb1806b28c891b9c856963325086ecaff32edec208f0a841865f26e8f3e refund-user-learning-analysis 648fd4ae2b29167fd66eab4245bdaaef00242db3131f4919cc02f07ca2a9b59c phone-chapter-query ac429b4da5a89db16efdf1066edf4ecb1c050b93aff20dd4c652af5f5568e44f -vala-order-amortization-stat de5f77b9e4ab865df5efe595e59e877257ac489a195fb969b9d20d1a71158b97 +vala-order-amortization-stat fd9da99f313347aa11a4cad8e5d0c9226eac8649b45db94dfa60e37015ab7491 diff --git a/memory/.dreams/short-term-recall.json b/memory/.dreams/short-term-recall.json new file mode 100644 index 0000000..c58eb7e --- /dev/null +++ b/memory/.dreams/short-term-recall.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "updatedAt": "2026-05-06T12:28:28.905Z", + "entries": { + "memory:memory/2026-05-06.md:1:20": { + "key": "memory:memory/2026-05-06.md:1:20", + "path": "memory/2026-05-06.md", + "startLine": 1, + "endLine": 20, + "source": "memory", + "snippet": "# 2026-05-06 工作日志 ## 任务1:学而思/讯飞/希沃/京东方月度渠道订单报表 - [李承龙需求] 创建按月汇总四个渠道订单的 Python 脚本 - 脚本路径:`/root/.openclaw/workspace/scripts/monthly_channel_order_report.py` - **渠道映射:** 仅端内 `key_from='app-active-h5-0-0'`,sale_channel: 21=学而思, 22=讯飞, 26=希沃, 27=京东方 - **输出格式:** 四个渠道各一个 Sheet,每月按天展示(学而思/希沃/京东方只显示有订单日期,讯飞显示全月每天) - **指标差异:** - 学而思/希沃/京东方:订单数、GMV、CPS分成(学而思×0.45,希沃×0.4,京东方×0.3)、订单号(分号拼接) - 讯飞:订单数、GMV、新增注册(download_channel LIKE '%讯飞%')、CPA分成(新增注册×7)、订单号 - **退费剔除:** NOT EXISTS (bi_refund_order.status=3) AND order_status != 4 - **测试运行:** 2026-04 月份,新增注册 537(讯飞),订单合计 15 单,GMV 14585 元 - **订单号格式问题:** Excel 科学计数法 → 设置单元格为文本格式解决 - **psycopg2 转义:** LIKE 中 `%` 需写成 `%%`(psycopg2 的 `%s` 占位符冲突) ## 任务2:销售线", + "recallCount": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-06T12:28:28.905Z", + "lastRecalledAt": "2026-05-06T12:28:28.905Z", + "queryHashes": [ + "c30fa9d13974" + ], + "recallDays": [ + "2026-05-06" + ], + "conceptTags": [ + "学而思/讯飞/希沃/京东方月度渠道订单报表", + "key-from", + "app-active-h5-0-0", + "sale-channel", + "学而思/希沃/京东方只显示有订单日期", + "学而思/希沃/京东方", + "0.45", + "0.4" + ] + } + } +} diff --git a/memory/2026-05-06-1025.md b/memory/2026-05-06-1025.md new file mode 100644 index 0000000..6b3d598 --- /dev/null +++ b/memory/2026-05-06-1025.md @@ -0,0 +1,5 @@ +# Session: 2026-05-06 10:25:14 GMT+8 + +- **Session Key**: agent:main:feishu:direct:ou_e63ce6b760ad39382852472f28fbe2a2 +- **Session ID**: 58b30963-b037-4f50-8b15-04e77ad8fef0 +- **Source**: feishu diff --git a/memory/2026-05-06-order-amortization-sql.md b/memory/2026-05-06-order-amortization-sql.md new file mode 100644 index 0000000..394a838 --- /dev/null +++ b/memory/2026-05-06-order-amortization-sql.md @@ -0,0 +1,53 @@ +# Session: 2026-05-06 16:53:32 GMT+8 + +- **Session Key**: agent:main:feishu:direct:ou_e63ce6b760ad39382852472f28fbe2a2 +- **Session ID**: 690e7978-4b15-4ce8-bfa3-9f8bb9271428 +- **Source**: feishu + +## Conversation Summary + +assistant: 让我尝试用其他方式读取这张图片: +assistant: OCR 识别不太完整。让我尝试提取为文本再做更好的识别: +assistant: OCR 效果有限,但结合上下文我能推断这个表格的结构。让我换个思路,下载图片后用 Python OCR: +assistant: 承龙,OCR 识别效果不理想,表格的汉字内容没能完全准确提取。让我基于已识别到的内容和你之前的需求上下文,整理一下我理解的表格结构,请你确认是否正确: + +根据 OCR 结果和上下文,这张表格应该是一个 **3行×3列** 的结构: + +| | 新增(金额) | 退费(金额) | +|---|---|---| +| **上月订单转正** | 下单时间在上个月且在上个月未转正的订单金额总和,剔除上个月全部退费的订单,部分退费只计算有效金额,不考虑本月退费情况 | 这些订单在本月的退费金额 | +| **本月订单转正** | 下单时间在本月且在本月已经转正的订单的金额总和,不考虑退费情况 | 这些订单在本月的退费金额 | +| **本月订单未转正** | 下单时间在本月且在本月未转正的订单的金额总和,不考虑退费情况 | 这些订单在本月的退费金额 | + +也就是: +- 行:上月订单转正、本月订单转正、本月订单未转正 +- 列:新增(即预收账款金额)、退费(这些订单在本账期的退费金额) + +如果理解正确,我需要确认几个问题: + +1. **"新增"列中的金额**,是订单的**含税金额(pay_amount)**还是**税后金额**?还是**退后金额(pay_amount - 退费)**? +2. **"退费"列**是指这些订单在本账期内发生的退费金额对吧? +3. 表格里的"上月"是相对于账期的(如 3 月账期,"上月"就是 2 月),"本月"就是账期所在月(3 月),对吗? +user: 1、是指订单的含税金额 +2、对的 +3、对的 +assistant: 好的,明确了。Sheet3 "本月预收账款" 结构如下: + +| | 新增(含税金额) | 退费 | +|---|---|---| +| **上月订单转正** | 上月下单+上月未转正+本月转正的订单;剔除上月已全额退费的;部分退费只算有效金额(pay - 退费);不考虑本月退费 | 这些订单在本月的退费金额 | +| **本月订单转正** | 本月下单+本月已转正的订单金额总和;不考虑退费 | 这些订单在本月的退费金额 | +| **本月订单未转正** | 本月下单+本月未转正的订单金额总和;不考虑退费 | 这些订单在本月的退费金额 | + +现在写 SQL。相对账期 "本月"=账期月,"上月"=账期月前一个月。需要计算这三个维度的新增和退费。 +assistant: 现在修改 `run.py`,增加 Sheet3 的支持: +assistant: 现在跑 3 月数据测试: +assistant: 运行成功。验证一下 Sheet3 的内容: +assistant: Sheet3 数据跑出来了。发送文件: +assistant: 文件已发送。三个 Sheet 分别是:汇总表、订单明细、本月预收账款。看看数据有没有问题? +user: [OpenClaw heartbeat poll] +assistant: `vala-order-amortization-stat` 有变更已推送成功。需要通知李若松。 +assistant: 好的,`vala-order-amortization-stat` 已推送到 SkillHub,需要通知李若松。李若松的 user_id 是 `4aagb443`。 +assistant: 已通知李若松。 + +HEARTBEAT_OK diff --git a/memory/2026-05-06.md b/memory/2026-05-06.md new file mode 100644 index 0000000..2755d3d --- /dev/null +++ b/memory/2026-05-06.md @@ -0,0 +1,36 @@ + +# 2026-05-06 工作日志 + +## 任务1:学而思/讯飞/希沃/京东方月度渠道订单报表 + +- [李承龙需求] 创建按月汇总四个渠道订单的 Python 脚本 +- 脚本路径:`/root/.openclaw/workspace/scripts/monthly_channel_order_report.py` +- **渠道映射:** 仅端内 `key_from='app-active-h5-0-0'`,sale_channel: 21=学而思, 22=讯飞, 26=希沃, 27=京东方 +- **输出格式:** 四个渠道各一个 Sheet,每月按天展示(学而思/希沃/京东方只显示有订单日期,讯飞显示全月每天) +- **指标差异:** + - 学而思/希沃/京东方:订单数、GMV、CPS分成(学而思×0.45,希沃×0.4,京东方×0.3)、订单号(分号拼接) + - 讯飞:订单数、GMV、新增注册(download_channel LIKE '%讯飞%')、CPA分成(新增注册×7)、订单号 +- **退费剔除:** NOT EXISTS (bi_refund_order.status=3) AND order_status != 4 +- **测试运行:** 2026-04 月份,新增注册 537(讯飞),订单合计 15 单,GMV 14585 元 +- **订单号格式问题:** Excel 科学计数法 → 设置单元格为文本格式解决 +- **psycopg2 转义:** LIKE 中 `%` 需写成 `%%`(psycopg2 的 `%s` 占位符冲突) + +## 任务2:销售线索用户分析报表 + +- [李承龙需求] 输入包含462个用户ID的Excel,输出每个用户+角色的完整画像报表 +- 脚本路径:`/root/.openclaw/workspace/scripts/lead_user_analysis.py` +- **重要口径修正(李承龙确认):** + - **体验课定义:** 固定为 L1 U00(L01-L05) + L2 U00(L01-L05) 共10节课,**不通过 expire_time 区分** + - L1 chapter_id: 343, 344, 345, 346, 348 + - L2 chapter_id: 55, 56, 57, 58, 59 + - **完成时间:** 取 `bi_user_chapter_play_record` 中 `play_status=1` 记录的**最早 `updated_at`**(非 `created_at`) +- **报表列:** 用户ID、角色ID、用户注册时间、角色创建时间、L1序章5节完成时间、L2序章5节完成时间、购买时间、购买课包名称、支付金额、购买渠道key_from、是否退款、退款金额 +- **分表规则:** `bi_user_chapter_play_record_{0..7}`,分表号 = user_id % 8 +- **重要关联:** `bi_user_chapter_play_record.user_id` = `bi_vala_app_character.id`(角色ID),非 account_id +- **退款条件:** `bi_refund_order.status=3` AND `bi_vala_order.order_status=4` +- **结果:** 533行(462个用户 × 533个有效角色),235个完成L1序章,139个完成L2序章,53行有购买,10行有退款 +- **踩坑记录:** + - 子会话按旧口径执行后被 kill,最终主会话亲自重写脚本 + - ALL_CHAPTERS 的值是 `"L1_U00_L01"`,COL_ORDER 是 `"L1_U00_L01_完成时间"`,不匹配导致全部为空,修复后正确 + - datetime 写入 Excel 需去掉时区信息 `dt.tz_localize(None)` + - play_record 分表查询需遍历 0-7 共 8 个分表 diff --git a/output/l2_pass_time_full.txt b/output/l2_pass_time_full.txt new file mode 100644 index 0000000..157c228 --- /dev/null +++ b/output/l2_pass_time_full.txt @@ -0,0 +1,245 @@ +S0|U00|L01|55|9898 +S0|U00|L02|56|6559 +S0|U00|L03|57|5265 +S0|U00|L04|58|4457 +S0|U00|L05|59|3927 +S1|U01|L01|70|1749 +S1|U01|L02|71|1644 +S1|U01|L03|72|1574 +S1|U01|L04|73|1498 +S1|U01|L05|74|1437 +S1|U02|L01|60|1348 +S1|U02|L02|61|1298 +S1|U02|L03|62|1257 +S1|U02|L04|63|1202 +S1|U02|L05|64|1166 +S1|U03|L01|78|1105 +S1|U03|L02|79|1073 +S1|U03|L03|80|1036 +S1|U03|L04|81|1017 +S1|U03|L05|82|997 +S1|U04|L01|84|950 +S1|U04|L02|85|934 +S1|U04|L03|86|906 +S1|U04|L04|87|892 +S1|U04|L05|88|875 +S1|U05|L01|89|841 +S1|U05|L02|90|820 +S1|U05|L03|91|807 +S1|U05|L04|92|792 +S1|U05|L05|93|780 +S1|U06|L01|94|758 +S1|U06|L02|95|746 +S1|U06|L03|96|732 +S1|U06|L04|97|716 +S1|U06|L05|98|707 +S1|U07|L01|99|685 +S1|U07|L02|100|669 +S1|U07|L03|101|657 +S1|U07|L04|102|655 +S1|U07|L05|103|640 +S1|U08|L01|104|625 +S1|U08|L02|105|621 +S1|U08|L03|106|616 +S1|U08|L04|107|611 +S1|U08|L05|108|605 +S1|U09|L01|142|593 +S1|U09|L02|143|589 +S1|U09|L03|144|588 +S1|U09|L04|145|581 +S1|U09|L05|146|577 +S1|U10|L01|114|565 +S1|U10|L02|115|561 +S1|U10|L03|116|558 +S1|U10|L04|117|555 +S1|U10|L05|118|554 +S1|U11|L01|119|545 +S1|U11|L02|120|538 +S1|U11|L03|121|536 +S1|U11|L04|122|534 +S1|U11|L05|123|531 +S1|U12|L01|125|524 +S1|U12|L02|126|519 +S1|U12|L03|127|517 +S1|U12|L04|128|513 +S1|U12|L05|129|508 +S2|U13|L01|147|342 +S2|U13|L02|148|337 +S2|U13|L03|149|334 +S2|U13|L04|150|333 +S2|U13|L05|151|329 +S2|U14|L01|152|325 +S2|U14|L02|153|324 +S2|U14|L03|154|320 +S2|U14|L04|155|317 +S2|U14|L05|156|316 +S2|U15|L01|157|309 +S2|U15|L02|158|307 +S2|U15|L03|159|303 +S2|U15|L04|160|298 +S2|U15|L05|161|295 +S2|U16|L01|162|289 +S2|U16|L02|163|287 +S2|U16|L03|164|285 +S2|U16|L04|165|285 +S2|U16|L05|166|285 +S2|U17|L01|167|283 +S2|U17|L02|168|281 +S2|U17|L03|169|280 +S2|U17|L04|170|279 +S2|U17|L05|171|278 +S2|U18|L01|172|272 +S2|U18|L02|173|272 +S2|U18|L03|174|270 +S2|U18|L04|175|264 +S2|U18|L05|176|261 +S2|U19|L01|177|259 +S2|U19|L02|178|259 +S2|U19|L03|179|259 +S2|U19|L04|180|256 +S2|U19|L05|181|252 +S2|U20|L01|182|250 +S2|U20|L02|183|250 +S2|U20|L03|184|249 +S2|U20|L04|185|247 +S2|U20|L05|186|244 +S2|U21|L01|187|241 +S2|U21|L02|188|239 +S2|U21|L03|189|239 +S2|U21|L04|190|238 +S2|U21|L05|191|238 +S2|U22|L01|192|234 +S2|U22|L02|193|231 +S2|U22|L03|194|231 +S2|U22|L04|195|230 +S2|U22|L05|196|229 +S2|U23|L01|197|229 +S2|U23|L02|198|229 +S2|U23|L03|199|227 +S2|U23|L04|200|226 +S2|U23|L05|201|226 +S2|U24|L01|202|225 +S2|U24|L02|203|224 +S2|U24|L03|204|224 +S2|U24|L04|205|223 +S2|U24|L05|206|223 +S3|U25|L01|207|143 +S3|U25|L02|208|143 +S3|U25|L03|209|141 +S3|U25|L04|210|140 +S3|U25|L05|211|140 +S3|U26|L01|212|140 +S3|U26|L02|213|140 +S3|U26|L03|214|140 +S3|U26|L04|215|140 +S3|U26|L05|216|140 +S3|U27|L01|217|139 +S3|U27|L02|218|139 +S3|U27|L03|219|138 +S3|U27|L04|220|137 +S3|U27|L05|221|137 +S3|U28|L01|222|137 +S3|U28|L02|223|137 +S3|U28|L03|224|137 +S3|U28|L04|225|135 +S3|U28|L05|226|135 +S3|U29|L01|227|135 +S3|U29|L02|228|135 +S3|U29|L03|229|135 +S3|U29|L04|230|135 +S3|U29|L05|231|135 +S3|U30|L01|232|127 +S3|U30|L02|233|127 +S3|U30|L03|234|127 +S3|U30|L04|235|127 +S3|U30|L05|236|127 +S3|U31|L01|237|127 +S3|U31|L02|238|127 +S3|U31|L03|239|127 +S3|U31|L04|240|127 +S3|U31|L05|241|127 +S3|U32|L01|242|127 +S3|U32|L02|244|127 +S3|U32|L03|245|127 +S3|U32|L04|246|127 +S3|U32|L05|247|127 +S3|U33|L01|248|127 +S3|U33|L02|249|127 +S3|U33|L03|250|127 +S3|U33|L04|251|127 +S3|U33|L05|252|127 +S3|U34|L01|254|127 +S3|U34|L02|255|127 +S3|U34|L03|256|127 +S3|U34|L04|257|127 +S3|U34|L05|258|127 +S3|U35|L01|259|127 +S3|U35|L02|260|127 +S3|U35|L03|261|127 +S3|U35|L04|262|127 +S3|U35|L05|264|127 +S3|U36|L01|265|127 +S3|U36|L02|266|127 +S3|U36|L03|267|127 +S3|U36|L04|268|127 +S3|U36|L05|269|127 +S4|U37|L01|270|127 +S4|U37|L02|271|127 +S4|U37|L03|272|127 +S4|U37|L04|273|127 +S4|U37|L05|274|127 +S4|U38|L01|276|127 +S4|U38|L02|277|127 +S4|U38|L03|278|127 +S4|U38|L04|279|127 +S4|U38|L05|280|127 +S4|U39|L01|281|127 +S4|U39|L02|282|127 +S4|U39|L03|283|127 +S4|U39|L04|284|127 +S4|U39|L05|285|127 +S4|U40|L01|286|127 +S4|U40|L02|287|127 +S4|U40|L03|288|127 +S4|U40|L04|289|127 +S4|U40|L05|290|127 +S4|U41|L01|291|127 +S4|U41|L02|292|127 +S4|U41|L03|293|127 +S4|U41|L04|294|127 +S4|U41|L05|295|127 +S4|U42|L01|296|127 +S4|U42|L02|297|127 +S4|U42|L03|298|127 +S4|U42|L04|299|127 +S4|U42|L05|300|127 +S4|U43|L01|301|127 +S4|U43|L02|302|127 +S4|U43|L03|303|127 +S4|U43|L04|304|127 +S4|U43|L05|305|127 +S4|U44|L01|306|127 +S4|U44|L02|307|127 +S4|U44|L03|308|127 +S4|U44|L04|309|127 +S4|U44|L05|310|127 +S4|U45|L01|312|127 +S4|U45|L02|313|127 +S4|U45|L03|314|127 +S4|U45|L04|315|127 +S4|U45|L05|316|127 +S4|U46|L01|317|127 +S4|U46|L02|318|127 +S4|U46|L03|319|127 +S4|U46|L04|320|127 +S4|U46|L05|321|127 +S4|U47|L01|322|127 +S4|U47|L02|323|127 +S4|U47|L03|324|127 +S4|U47|L04|325|127 +S4|U47|L05|326|127 +S4|U48|L01|327|127 +S4|U48|L02|328|127 +S4|U48|L03|329|127 +S4|U48|L04|330|127 +S4|U48|L05|331|127 diff --git a/scripts/l2_pass_time.py b/scripts/l2_pass_time.py new file mode 100644 index 0000000..08ea156 --- /dev/null +++ b/scripts/l2_pass_time.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""L2 每个课时首次通关耗时统计""" +import psycopg2 +import sys + +conn = psycopg2.connect( + host="bj-postgres-16pob4sg.sql.tencentcdb.com", + port=28591, + user="ai_member", + password="LdfjdjL83h3h3^$&**YGG*", + dbname="vala_bi" +) +cur = conn.cursor() + +# 获取 L2 所有章节 +cur.execute(""" + SELECT id, course_season, course_unit, course_lesson + FROM bi_level_unit_lesson + WHERE course_level = 'L2' + ORDER BY course_season, course_unit, course_lesson +""") +chapters = cur.fetchall() + +results = [] +for cid, season, unit, lesson in chapters: + # 从8张分表收集该章节的首次完成记录 + first_dones = {} # (user_id, chapter_unique_id) -> first_done_time + for tbl_idx in range(8): + cur.execute(f""" + SELECT user_id, chapter_unique_id, MIN(created_at) + FROM bi_user_chapter_play_record_{tbl_idx} + WHERE play_status = 1 AND chapter_id = {cid} + GROUP BY user_id, chapter_unique_id + """) + for row in cur.fetchall(): + uid, cu_id, ts = row + key = (uid, cu_id) + if key not in first_dones or ts < first_dones[key][0]: + first_dones[key] = (ts, cu_id) + + if not first_dones: + results.append((season, unit, lesson, cid, 0, 0.0)) + continue + + # 对每个首次完成记录,统计组件总耗时 + total_times = [] + for (uid, cu_id), (_, cu_id) in first_dones.items(): + total_ms = 0 + for comp_idx in range(8): + cur.execute(f""" + SELECT COALESCE(SUM(interval_time), 0) + FROM bi_user_component_play_record_{comp_idx} + WHERE chapter_unique_id = %s + """, (cu_id,)) + total_ms += cur.fetchone()[0] or 0 + total_times.append(total_ms) + + avg_min = sum(total_times) / len(total_times) / 60000.0 + results.append((season, unit, lesson, cid, len(total_times), round(avg_min, 1))) + + print(f" {season} {unit} {lesson} (id={cid}): {len(total_times)}人, 平均{round(avg_min, 1)}分钟") + +# 输出汇总 +print("\n===== 汇总 =====") +print(f"{'Season':<6} {'Unit':<6} {'Lesson':<8} {'ChapterID':<10} {'完成人数':<10} {'平均用时(分钟)':<15}") +for r in results: + print(f"{r[0]:<6} {r[1]:<6} {r[2]:<8} {r[3]:<10} {r[4]:<10} {r[5]:<15}") + +# 按单元汇总 +from collections import defaultdict +unit_stats = defaultdict(lambda: {"count": 0, "avg": 0.0, "lessons": 0}) +for r in results: + key = f"{r[0]}_{r[1]}" + unit_stats[key]["count"] += r[4] + unit_stats[key]["avg"] += r[5] + unit_stats[key]["lessons"] += 1 + +print("\n===== 按单元汇总 =====") +print(f"{'Unit':<10} {'完成人数(合计)':<15} {'平均用时(分钟)':<15}") +for key, stats in sorted(unit_stats.items()): + print(f"{key:<10} {stats['count']:<15} {round(stats['avg']/stats['lessons'], 1):<15}") + +cur.close() +conn.close() diff --git a/scripts/l2_pass_time.sh b/scripts/l2_pass_time.sh new file mode 100755 index 0000000..d52de0c --- /dev/null +++ b/scripts/l2_pass_time.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# L2 每个课时首次通关耗时统计 +export PGPASSWORD='LdfjdjL83h3h3^$&**YGG*' +PGHOST='bj-postgres-16pob4sg.sql.tencentcdb.com' +PGPORT='28591' +PGUSER='ai_member' +PGDATABASE='vala_bi' +PSQL="psql -h \$PGHOST -p \$PGPORT -U \$PGUSER -d \$PGDATABASE -t -A" + +# 获取所有 L2 章节 +$PSQL -c "SELECT id FROM bi_level_unit_lesson WHERE course_level='L2' ORDER BY id;" > /tmp/l2_ch_ids.txt + +echo "chapter_id|season|unit|lesson|完成人数|平均用时_分钟" +while read cid; do + # 获取章节信息 + info=$($PSQL -c "SELECT course_season||'|'||course_unit||'|'||course_lesson FROM bi_level_unit_lesson WHERE id=$cid;") + + # 统计首次完成: 从8张分表收集 + result=$($PSQL -c " + WITH fd AS ( + SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_0 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_1 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_2 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_3 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_4 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_5 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_6 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id, chapter_unique_id FROM bi_user_chapter_play_record_7 WHERE play_status=1 AND chapter_id=$cid + ), + times AS ( + SELECT fd.chapter_unique_id, COALESCE(SUM(c.interval_time),0) AS total_ms FROM fd + LEFT JOIN ( + SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_0 + UNION ALL SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_1 + UNION ALL SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_2 + UNION ALL SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_3 + UNION ALL SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_4 + UNION ALL SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_5 + UNION ALL SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_6 + UNION ALL SELECT chapter_unique_id, interval_time FROM bi_user_component_play_record_7 + ) c ON fd.chapter_unique_id = c.chapter_unique_id + GROUP BY fd.chapter_unique_id + ) + SELECT COUNT(*), ROUND(AVG(total_ms)/60000.0, 1) FROM times WHERE total_ms > 0; + " 2>&1) + + echo "$cid|$info|$result" +done < /tmp/l2_ch_ids.txt diff --git a/scripts/l2_pass_time.sql b/scripts/l2_pass_time.sql new file mode 100644 index 0000000..637e02f --- /dev/null +++ b/scripts/l2_pass_time.sql @@ -0,0 +1,89 @@ +-- L2 每个课时首次通关耗时统计 +-- 遍历 8 张 user_chapter_play_record 分表 + +WITH l2_chapters AS ( + SELECT id AS chapter_id, course_season, course_unit, course_lesson + FROM bi_level_unit_lesson + WHERE course_level = 'L2' +), +all_first_done AS ( + SELECT chapter_id, user_id, chapter_unique_id, first_done_time + FROM ( + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_0 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + UNION ALL + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_1 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + UNION ALL + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_2 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + UNION ALL + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_3 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + UNION ALL + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_4 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + UNION ALL + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_5 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + UNION ALL + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_6 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + UNION ALL + SELECT chapter_id, user_id, chapter_unique_id, MIN(created_at) AS first_done_time + FROM bi_user_chapter_play_record_7 + WHERE play_status = 1 AND chapter_id IN (SELECT chapter_id FROM l2_chapters) + GROUP BY chapter_id, user_id, chapter_unique_id + ) t +), +chapter_time AS ( + SELECT + fd.chapter_id, + fd.chapter_unique_id, + SUM(comp.interval_time) AS total_ms + FROM all_first_done fd + JOIN LATERAL ( + SELECT SUM(c.interval_time) AS interval_time + FROM bi_user_component_play_record_0 c WHERE c.chapter_unique_id = fd.chapter_unique_id + UNION ALL + SELECT SUM(c.interval_time) FROM bi_user_component_play_record_1 c WHERE c.chapter_unique_id = fd.chapter_unique_id + UNION ALL + SELECT SUM(c.interval_time) FROM bi_user_component_play_record_2 c WHERE c.chapter_unique_id = fd.chapter_unique_id + UNION ALL + SELECT SUM(c.interval_time) FROM bi_user_component_play_record_3 c WHERE c.chapter_unique_id = fd.chapter_unique_id + UNION ALL + SELECT SUM(c.interval_time) FROM bi_user_component_play_record_4 c WHERE c.chapter_unique_id = fd.chapter_unique_id + UNION ALL + SELECT SUM(c.interval_time) FROM bi_user_component_play_record_5 c WHERE c.chapter_unique_id = fd.chapter_unique_id + UNION ALL + SELECT SUM(c.interval_time) FROM bi_user_component_play_record_6 c WHERE c.chapter_unique_id = fd.chapter_unique_id + UNION ALL + SELECT SUM(c.interval_time) FROM bi_user_component_play_record_7 c WHERE c.chapter_unique_id = fd.chapter_unique_id + ) comp ON true + GROUP BY fd.chapter_id, fd.chapter_unique_id +) +SELECT + lc.course_season, + lc.course_unit, + lc.course_lesson, + lc.chapter_id, + COUNT(*) AS 完成人数, + ROUND(AVG(ct.total_ms) / 60000.0, 1) AS 平均通关用时_分钟 +FROM l2_chapters lc +LEFT JOIN chapter_time ct ON lc.chapter_id = ct.chapter_id +GROUP BY lc.course_season, lc.course_unit, lc.course_lesson, lc.chapter_id +ORDER BY lc.course_season, lc.course_unit, lc.course_lesson; diff --git a/scripts/l2_pass_time_v2.sh b/scripts/l2_pass_time_v2.sh new file mode 100644 index 0000000..5fca9ee --- /dev/null +++ b/scripts/l2_pass_time_v2.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# L2 每个课时首次通关耗时统计 - 优化版 +export PGPASSWORD='LdfjdjL83h3h3^$&**YGG*' +PSQL="psql -h bj-postgres-16pob4sg.sql.tencentcdb.com -p 28591 -U ai_member -d vala_bi -t -A" + +echo "chapter_id|season|unit|lesson|完成人数|平均用时_分钟" + +# 获取章节列表 +while IFS='|' read -r cid season unit lesson; do + # 查首次完成记录,来自8张分表 (不查 component 耗时那步太慢,直接用 chapter 表做简单计数) + # 先只统计完成人数 + count=$($PSQL -c " + SELECT COUNT(*) + FROM ( + SELECT user_id FROM bi_user_chapter_play_record_0 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id FROM bi_user_chapter_play_record_1 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id FROM bi_user_chapter_play_record_2 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id FROM bi_user_chapter_play_record_3 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id FROM bi_user_chapter_play_record_4 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id FROM bi_user_chapter_play_record_5 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id FROM bi_user_chapter_play_record_6 WHERE play_status=1 AND chapter_id=$cid + UNION ALL SELECT user_id FROM bi_user_chapter_play_record_7 WHERE play_status=1 AND chapter_id=$cid + ) t; + " 2>/dev/null || echo "0") + + echo "$cid|$season|$unit|$lesson|${count:-0}|NA" +done < <($PSQL -c "SELECT id, course_season, course_unit, course_lesson FROM bi_level_unit_lesson WHERE course_level='L2' ORDER BY id;") diff --git a/scripts/lead_user_analysis.py b/scripts/lead_user_analysis.py new file mode 100644 index 0000000..5aabe87 --- /dev/null +++ b/scripts/lead_user_analysis.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +销售线索用户分析报表 + +输入:Excel文件,包含「用户ID」列 +输出:每个用户+角色一行,包含注册信息、序章完成情况、购买和退款信息 + +修正口径: +- 体验课 = 固定10节课:L1 U00 L01-L05 (chapter_id: 343,344,345,346,348) + L2 U00 L01-L05 (55-59) +- 完成时间 = play_status=1 的最早 updated_at +""" + +import os +import sys +import psycopg2 +import pandas as pd +from collections import defaultdict + +# ── 数据库 ── +DB_HOST = "bj-postgres-16pob4sg.sql.tencentcdb.com" +DB_PORT = 28591 +DB_USER = "ai_member" +DB_NAME = "vala_bi" + +def get_password(): + secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "secrets.env") + # also try env + pw = os.environ.get("PG_ONLINE_PASSWORD", "") + if pw: + return pw + if os.path.exists(secrets_path): + with open(secrets_path) as f: + for line in f: + if line.startswith("PG_ONLINE_PASSWORD="): + return line.strip().split("=", 1)[1].strip("'\"") + raise RuntimeError("PG_ONLINE_PASSWORD not found") + +def get_conn(): + return psycopg2.connect(host=DB_HOST, port=DB_PORT, user=DB_USER, password=get_password(), dbname=DB_NAME, connect_timeout=30) + +# ── 序章 chapter_id ── +# L1 S0 U00 L01..L05 +L1_CHAPTERS = {343: "L1_U00_L01", 344: "L1_U00_L02", 345: "L1_U00_L03", 346: "L1_U00_L04", 348: "L1_U00_L05"} +# L2 S0 U00 L01..L05 +L2_CHAPTERS = {55: "L2_U00_L01", 56: "L2_U00_L02", 57: "L2_U00_L03", 58: "L2_U00_L04", 59: "L2_U00_L05"} + +ALL_CHAPTERS = {**L1_CHAPTERS, **L2_CHAPTERS} +COL_ORDER = [ + "L1_U00_L01_完成时间", "L1_U00_L02_完成时间", "L1_U00_L03_完成时间", + "L1_U00_L04_完成时间", "L1_U00_L05_完成时间", + "L2_U00_L01_完成时间", "L2_U00_L02_完成时间", "L2_U00_L03_完成时间", + "L2_U00_L04_完成时间", "L2_U00_L05_完成时间", +] + +def main(): + input_file = sys.argv[1] if len(sys.argv) > 1 else "/root/.openclaw/media/inbound/3æ_è_çº_çº_ç---d9a41af7-b100-43a7-a983-d4fd1f164023.xlsx" + print(f"读取输入文件: {input_file}") + df_input = pd.read_excel(input_file, dtype=str) + user_ids_raw = df_input["用户ID"].dropna().unique().tolist() + account_ids = [int(x) for x in user_ids_raw] + print(f"共 {len(account_ids)} 个用户ID") + + conn = get_conn() + + # ── 1. 获取用户基本信息(account) ── + placeholders = ",".join(["%s"] * len(account_ids)) + df_accounts = pd.read_sql_query( + f"SELECT id AS account_id, created_at AS reg_time FROM bi_vala_app_account WHERE id IN ({placeholders}) AND status = 1", + conn, params=account_ids + ) + print(f" 有效账户: {len(df_accounts)}") + + # ── 2. 获取角色(排除 nickname 为空的) ── + df_chars = pd.read_sql_query( + f"SELECT id AS character_id, account_id, nickname, created_at AS char_created_at FROM bi_vala_app_character WHERE account_id IN ({placeholders}) AND (nickname IS NOT NULL AND nickname != '') AND deleted_at IS NULL", + conn, params=account_ids + ) + print(f" 有效角色: {len(df_chars)}") + + if df_chars.empty: + print("没有有效的角色,退出") + conn.close() + return + + # ── 3. 查询课时完成记录(10个 chapter_id,8个分表) ── + chapter_ids = list(ALL_CHAPTERS.keys()) + char_id_set = set(df_chars["character_id"].tolist()) + char_play = defaultdict(dict) + total_play = 0 + + for tbl_idx in range(8): + table = f"bi_user_chapter_play_record_{tbl_idx}" + sql = f""" + SELECT user_id, chapter_id, MIN(updated_at) AS done_time + FROM {table} + WHERE chapter_id IN %s + AND play_status = 1 + AND deleted_at IS NULL + GROUP BY user_id, chapter_id + """ + try: + cur = conn.cursor() + cur.execute(sql, (tuple(chapter_ids),)) + rows = cur.fetchall() + cur.close() + total_play += len(rows) + for user_id, ch_id, done_time in rows: + if user_id in char_id_set: + label = ALL_CHAPTERS.get(ch_id) + if label: + char_play[user_id][ch_id] = done_time + except Exception as e: + print(f" 警告: {table} 查询失败: {e}") + + print(f" 课时完成记录: {total_play} 条, 匹配角色: {len(char_play)}") + + # ── 4. 订单信息 ── + df_orders = pd.read_sql_query( + f""" + SELECT o.account_id, o.pay_success_date, o.goods_name, o.pay_amount_int, o.key_from, o.trade_no, o.order_status + FROM bi_vala_order o + INNER JOIN bi_vala_app_account a ON o.account_id = a.id AND a.status = 1 + WHERE o.account_id IN ({placeholders}) + AND o.deleted_at IS NULL + ORDER BY o.account_id, o.pay_success_date + """, + conn, params=account_ids + ) + print(f" 订单记录: {len(df_orders)}") + + # ── 5. 退款信息 ── + all_trade_nos = df_orders["trade_no"].dropna().unique().tolist() + refund_map = {} + if all_trade_nos: + # 分批查询(防止IN子句过长) + for i in range(0, len(all_trade_nos), 500): + batch = all_trade_nos[i:i+500] + ph = ",".join(["%s"] * len(batch)) + cur = conn.cursor() + cur.execute(f"SELECT trade_no, SUM(refund_amount_int) FROM bi_refund_order WHERE trade_no IN ({ph}) AND status = 3 AND deleted_at IS NULL GROUP BY trade_no", batch) + for trade_no, amt in cur.fetchall(): + refund_map[trade_no] = amt + cur.close() + conn.close() + print(f" 退费记录: {len(refund_map)} 条") + + # ── 6. 原始文件线索信息 ── + df_input["用户ID_int"] = df_input["用户ID"].astype(int) + df_input_map = df_input[["用户ID_int", "线索进线日期", "销售"]].drop_duplicates(subset="用户ID_int") + + # ── 7. 组装结果 ── + df_chars = df_chars.merge(df_accounts, on="account_id", how="left") + + # 去掉时区信息(Excel不支持) + for col in ["reg_time", "char_created_at"]: + if col in df_chars.columns: + df_chars[col] = pd.to_datetime(df_chars[col]).dt.tz_localize(None) + + rows = [] + for _, char_row in df_chars.iterrows(): + account_id = int(char_row["account_id"]) + char_id = int(char_row["character_id"]) + reg_time = char_row["reg_time"] + char_created_at = char_row["char_created_at"] + + # 课时完成 + play_map = char_play.get(char_id, {}) + row_data = { + "用户ID": account_id, + "角色ID": char_id, + "用户注册时间": reg_time, + "角色创建时间": char_created_at, + } + for col_label in COL_ORDER: + # 找到对应的 chapter_id(ALL_CHAPTERS 的 value 是去掉 "_完成时间" 后缀的) + ch_id = None + lbl_key = col_label.replace("_完成时间", "") + for cid, lbl in ALL_CHAPTERS.items(): + if lbl == lbl_key: + ch_id = cid + break + done_time = play_map.get(ch_id, None) + if done_time is not None: + done_time = done_time.replace(tzinfo=None) + row_data[col_label] = done_time + + rows.append(row_data) + + df_result = pd.DataFrame(rows) + + # ── 7. 合并订单 & 退款 ── + # 按 account_id 聚合 + order_agg = df_orders.groupby("account_id").agg( + 购买时间=("pay_success_date", lambda x: ";".join(str(v) for v in x if pd.notna(v))), + 购买课包名称=("goods_name", lambda x: ";".join(str(v) for v in x if pd.notna(v))), + 支付金额=("pay_amount_int", lambda x: ";".join(str(v/100) for v in x if pd.notna(v))), + 购买渠道key_from=("key_from", lambda x: ";".join(str(v) for v in x if pd.notna(v))), + trade_nos=("trade_no", lambda x: list(x)), + ).reset_index() + + # 退款判断 + def calc_refund(row): + has_refund = False + total_refund = 0 + for tn in row["trade_nos"]: + if tn in refund_map: + has_refund = True + total_refund += refund_map[tn] + # 也检查 order_status=4 + for tn in row["trade_nos"]: + if tn not in refund_map: + # 查订单状态 + pass # order_status 已在订单表中 + return pd.Series({"是否退款": "是" if has_refund else "否", "退款金额": total_refund / 100.0}) + + refund_info = order_agg.apply(calc_refund, axis=1) + order_agg = pd.concat([order_agg[["account_id", "购买时间", "购买课包名称", "支付金额", "购买渠道key_from"]], refund_info], axis=1) + + # 合并到结果 + df_result["account_id_int"] = df_result["用户ID"].astype(int) + df_result = df_result.merge(order_agg, left_on="account_id_int", right_on="account_id", how="left") + df_result.drop(columns=["account_id"], inplace=True, errors="ignore") + + # 合并原始文件的线索进线日期和销售 + df_result = df_result.merge(df_input_map, left_on="account_id_int", right_on="用户ID_int", how="left") + df_result.drop(columns=["account_id_int", "用户ID_int"], inplace=True, errors="ignore") + + # 填充空值 + df_result["购买时间"] = df_result["购买时间"].fillna("") + df_result["购买课包名称"] = df_result["购买课包名称"].fillna("") + df_result["支付金额"] = df_result["支付金额"].fillna("") + df_result["购买渠道key_from"] = df_result["购买渠道key_from"].fillna("") + df_result["是否退款"] = df_result["是否退款"].fillna("否") + df_result["退款金额"] = df_result["退款金额"].fillna(0.0) + df_result["线索进线日期"] = df_result["线索进线日期"].fillna("") + df_result["销售"] = df_result["销售"].fillna("") + + # 按用户ID升序排列 + df_result = df_result.sort_values(by=["用户ID", "角色ID"], ascending=True).reset_index(drop=True) + + # 调整列顺序:原始字段靠前 + col_order = ["用户ID", "线索进线日期", "销售", "角色ID", "用户注册时间", "角色创建时间"] + COL_ORDER + ["购买时间", "购买课包名称", "支付金额", "购买渠道key_from", "是否退款", "退款金额"] + # 只保留实际存在的列 + col_order = [c for c in col_order if c in df_result.columns] + df_result = df_result[col_order] + + # ── 8. 输出 ── + output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output") + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, "销售线索_用户分析.xlsx") + + with pd.ExcelWriter(output_path, engine="openpyxl") as writer: + df_result.to_excel(writer, sheet_name="用户分析", index=False) + + print(f"\n✅ 报表已生成: {output_path}") + print(f" 总行数: {len(df_result)}") + purchased = sum(1 for v in df_result["购买时间"] if v) + print(f" 有购买记录: {purchased}") + refunded = sum(1 for v in df_result["是否退款"] if v == "是") + print(f" 有退款: {refunded}") + done_l1 = sum(1 for v in df_result["L1_U00_L01_完成时间"] if pd.notna(v)) + done_l2 = sum(1 for v in df_result["L2_U00_L01_完成时间"] if pd.notna(v)) + print(f" 完成L1序章(U00 L01): {done_l1} 个角色") + print(f" 完成L2序章(U00 L01): {done_l2} 个角色") + + +if __name__ == "__main__": + main() diff --git a/scripts/monthly_channel_order_report.py b/scripts/monthly_channel_order_report.py new file mode 100644 index 0000000..23c872b --- /dev/null +++ b/scripts/monthly_channel_order_report.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +月度学而思/讯飞/希沃/京东方渠道订单汇总报表 + +功能: + - 按指定月份,汇总这4个渠道每一天的: + 新增注册数、订单数(剔除退费)、GMV(有效订单金额加和) + - 自动剔除测试账号 + - 输出 Excel 文件到 output/ 目录 + +渠道映射(sale_channel,仅对 key_from='app-active-h5-0-0' 的订单生效): + 21 = 学而思 + 22 = 讯飞 + 26 = 希沃 + 27 = 京东方 + +新增注册:download_channel LIKE '%学而思%' / '%讯飞%' / '%希沃%' / '%京东方%' +""" + +import os +import sys +from datetime import datetime, timedelta +import calendar +import psycopg2 +import pandas as pd + +# ── 数据库连接 ────────────────────────────────────────────── +DB_HOST = "bj-postgres-16pob4sg.sql.tencentcdb.com" +DB_PORT = 28591 +DB_USER = "ai_member" +DB_NAME = "vala_bi" +DB_PASSWORD = os.environ.get("PG_ONLINE_PASSWORD", "") + +if not DB_PASSWORD: + # 尝试从 secrets.env 读取 + secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "secrets.env") + if os.path.exists(secrets_path): + with open(secrets_path) as f: + for line in f: + if line.startswith("PG_ONLINE_PASSWORD="): + DB_PASSWORD = line.strip().split("=", 1)[1].strip("'\"") + break + +# ── 渠道配置 ──────────────────────────────────────────────── +# (sale_channel值, 渠道中文名, CPS分成比例, None表示不用CPS分成) +CHANNELS = [ + ("21", "学而思", 0.45), + ("22", "讯飞", None), + ("26", "希沃", 0.40), + ("27", "京东方", 0.30), +] + +# ── 参数 ──────────────────────────────────────────────────── +def parse_month(arg: str) -> str: + """将用户输入解析为 YYYY-MM 格式,默认当前月""" + arg = arg.strip() + if len(arg) == 7 and arg[4] == "-": + return arg + if len(arg) == 6 and arg.isdigit(): + return f"{arg[:4]}-{arg[4:]}" + # 默认当前月 + return datetime.now().strftime("%Y-%m") + + +def get_conn(): + return psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + dbname=DB_NAME, + connect_timeout=10, + ) + + +# ── SQL 定义 ──────────────────────────────────────────────── + +# 新增注册:按天 + 渠道汇总(这4个渠道 download_channel 关键字包含匹配) +SQL_NEW_USERS = """ +SELECT + DATE(created_at) AS reg_date, + CASE + WHEN download_channel LIKE '%%学而思%%' THEN '学而思' + WHEN download_channel LIKE '%%讯飞%%' THEN '讯飞' + WHEN download_channel LIKE '%%希沃%%' THEN '希沃' + WHEN download_channel LIKE '%%京东方%%' THEN '京东方' + END AS channel_name, + COUNT(*) AS new_users +FROM bi_vala_app_account +WHERE status = 1 + AND deleted_at IS NULL + AND created_at >= %s + AND created_at < %s + AND ( + download_channel LIKE '%%学而思%%' + OR download_channel LIKE '%%讯飞%%' + OR download_channel LIKE '%%希沃%%' + OR download_channel LIKE '%%京东方%%' + ) +GROUP BY reg_date, channel_name +ORDER BY reg_date; +""" + +# 有效订单(剔除全部退费订单):端内 + sale_channel 在 21/22/26/27 +# 剔除逻辑:排除 order_status=4 且 bi_refund_order.status=3 的订单 +# 逐条查出,在 Python 中聚合(因为需要保留 trade_no 明细) +SQL_VALID_ORDERS = """ +SELECT + DATE(o.pay_success_date) AS order_date, + CASE o.sale_channel + WHEN 21 THEN '学而思' + WHEN 22 THEN '讯飞' + WHEN 26 THEN '希沃' + WHEN 27 THEN '京东方' + END AS channel_name, + o.pay_amount_int, + o.trade_no +FROM bi_vala_order o +INNER JOIN bi_vala_app_account a ON o.account_id = a.id AND a.status = 1 +WHERE o.key_from = 'app-active-h5-0-0' + AND o.sale_channel IN (21, 22, 26, 27) + AND o.pay_success_date >= %s + AND o.pay_success_date < %s + AND o.deleted_at IS NULL + -- 剔除已退费订单 + AND NOT EXISTS ( + SELECT 1 FROM bi_refund_order r + WHERE r.trade_no = o.trade_no + AND r.status = 3 + AND r.deleted_at IS NULL + ) + AND o.order_status != 4 +ORDER BY order_date, channel_name; +""" + + +# ── 主逻辑 ────────────────────────────────────────────────── + +def run(month_str: str): + print(f"开始生成 {month_str} 学而思/讯飞/希沃/京东方渠道汇总报表...") + + # 解析月份起止 + year, month = int(month_str[:4]), int(month_str[5:]) + _, last_day = calendar.monthrange(year, month) + start_ts = f"{month_str}-01 00:00:00+08:00" + # 下月1号(用 datetime 自动处理跨月/跨年) + next_month = datetime(year, month, 1) + timedelta(days=last_day) + end_ts = next_month.strftime("%Y-%m-%d 00:00:00+08:00") + + conn = get_conn() + try: + # 1. 新增注册 + cur = conn.cursor() + cur.execute(SQL_NEW_USERS, (start_ts, end_ts)) + rows_new = cur.fetchall() + df_new = pd.DataFrame(rows_new, columns=[desc[0] for desc in cur.description]) if rows_new else pd.DataFrame(columns=["reg_date", "channel_name", "new_users"]) + cur.close() + print(f" 新增注册记录: {len(df_new)} 行") + + # 2. 有效订单(逐条) + cur = conn.cursor() + cur.execute(SQL_VALID_ORDERS, (start_ts, end_ts)) + rows_ord = cur.fetchall() + df_orders_raw = pd.DataFrame(rows_ord, columns=[desc[0] for desc in cur.description]) if rows_ord else pd.DataFrame(columns=["order_date", "channel_name", "pay_amount_int", "trade_no"]) + cur.close() + print(f" 有效订单记录: {len(df_orders_raw)} 行") + + # 按 channel_name + order_date 聚合成每日汇总 + if not df_orders_raw.empty: + df_orders = df_orders_raw.groupby(["channel_name", "order_date"], as_index=False).agg( + order_cnt=("pay_amount_int", "count"), + gmv_cents=("pay_amount_int", "sum"), + trade_nos=("trade_no", lambda x: ";".join(str(t) for t in x)), + ) + else: + df_orders = pd.DataFrame(columns=["channel_name", "order_date", "order_cnt", "gmv_cents", "trade_nos"]) + finally: + conn.close() + + # 3. 构建每个渠道的结果表(每一天一行) + date_range = [ + f"{month_str}-{d:02d}" for d in range(1, last_day + 1) + ] + + channel_names = [ch[1] for ch in CHANNELS] + channel_dfs = {} + channel_totals = {} + + for ch_val, ch_name, cps_rate in CHANNELS: + if cps_rate is not None: + # ── 学而思/希沃/京东方:只展示有订单的日期,无新增注册列,有CPS分成 ── + rows = [] + for d in date_range: + day_ord = df_orders[(df_orders["order_date"].astype(str) == d) & (df_orders["channel_name"] == ch_name)] + if day_ord.empty: + continue + order_cnt = int(day_ord["order_cnt"].sum()) + gmv = day_ord["gmv_cents"].sum() / 100.0 + trade_nos = ";".join(day_ord["trade_nos"].values) + rows.append({ + "日期": d, + "订单数": order_cnt, + "GMV(元)": round(gmv, 2), + "CPS分成(元)": round(gmv * cps_rate, 2), + "订单号": trade_nos, + }) + + if rows: + df_sheet = pd.DataFrame(rows) + total = { + "日期": "合计", + "订单数": df_sheet["订单数"].sum(), + "GMV(元)": round(df_sheet["GMV(元)"].sum(), 2), + "CPS分成(元)": round(df_sheet["CPS分成(元)"].sum(), 2), + "订单号": "", + } + else: + df_sheet = pd.DataFrame(columns=["日期", "订单数", "GMV(元)", "CPS分成(元)", "订单号"]) + total = {"日期": "合计", "订单数": 0, "GMV(元)": 0.0, "CPS分成(元)": 0.0, "订单号": ""} + df_sheet = pd.concat([df_sheet, pd.DataFrame([total])], ignore_index=True) + channel_dfs[ch_name] = df_sheet + channel_totals[ch_name] = total + + else: + # ── 讯飞:每天一行,含新增注册 + CPA分成 + 订单号 ── + rows = [] + for d in date_range: + day_new = df_new[(df_new["reg_date"].astype(str) == d) & (df_new["channel_name"] == ch_name)] + new_users = int(day_new["new_users"].sum()) if not day_new.empty else 0 + + day_ord = df_orders[(df_orders["order_date"].astype(str) == d) & (df_orders["channel_name"] == ch_name)] + if not day_ord.empty: + order_cnt = int(day_ord["order_cnt"].sum()) + gmv = day_ord["gmv_cents"].sum() / 100.0 + trade_nos = ";".join(day_ord["trade_nos"].values) + else: + order_cnt = 0 + gmv = 0.0 + trade_nos = "" + + rows.append({ + "日期": d, + "新增注册": new_users, + "订单数": order_cnt, + "GMV(元)": round(gmv, 2), + "CPA分成(元)": round(new_users * 7, 2), + "订单号": trade_nos, + }) + + df_sheet = pd.DataFrame(rows) + total = { + "日期": "合计", + "新增注册": df_sheet["新增注册"].sum(), + "订单数": df_sheet["订单数"].sum(), + "GMV(元)": round(df_sheet["GMV(元)"].sum(), 2), + "CPA分成(元)": round(df_sheet["CPA分成(元)"].sum(), 2), + "订单号": "", + } + df_sheet = pd.concat([df_sheet, pd.DataFrame([total])], ignore_index=True) + channel_dfs[ch_name] = df_sheet + channel_totals[ch_name] = total + + # 4. 输出 Excel(4个Sheet,每个渠道一张) + output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output") + os.makedirs(output_dir, exist_ok=True) + filename = f"学而思讯飞希沃京东方_{month_str}_渠道汇总.xlsx" + output_path = os.path.join(output_dir, filename) + + with pd.ExcelWriter(output_path, engine="openpyxl") as writer: + for ch_name in channel_names: + df_sheet = channel_dfs[ch_name] + df_sheet.to_excel(writer, sheet_name=ch_name, index=False) + # 将"订单号"列设为文本格式,防止长数字被科学计数法显示 + ws = writer.sheets[ch_name] + # 找到"订单号"列的位置 + headers = list(df_sheet.columns) + if "订单号" in headers: + col_idx = headers.index("订单号") + col_letter = chr(65 + col_idx) # A=65 + for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=col_idx+1, max_col=col_idx+1): + for cell in row: + if cell.value and str(cell.value).strip(): + cell.number_format = '@' # 文本格式 + + # 5. 打印摘要 + print(f"\n✅ 报表已生成: {output_path}") + print(f" 共 {last_day} 天数据 × 4 个渠道") + grand_orders = sum(t["订单数"] for t in channel_totals.values()) + grand_gmv = sum(t["GMV(元)"] for t in channel_totals.values()) + for ch_name in channel_names: + t = channel_totals[ch_name] + extra = "" + if "新增注册" in t: + extra += f", 新增注册={t['新增注册']}" + if "CPA分成(元)" in t: + extra += f", CPA分成={t['CPA分成(元)']}元" + if "CPS分成(元)" in t: + extra += f", CPS分成={t['CPS分成(元)']}元" + print(f" {ch_name}: 订单数={t['订单数']}, GMV={t['GMV(元)']}元{extra}") + print(f" 合计: 订单数={grand_orders}, GMV={grand_gmv}元") + + return output_path + + +if __name__ == "__main__": + month_input = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m") + month_str = parse_month(month_input) + run(month_str) diff --git a/skills/vala-order-amortization-stat/SKILL.md b/skills/vala-order-amortization-stat/SKILL.md index 2741afb..7dc2183 100644 --- a/skills/vala-order-amortization-stat/SKILL.md +++ b/skills/vala-order-amortization-stat/SKILL.md @@ -392,7 +392,7 @@ FROM summary_calc; 1. 优先输出Excel报表,存放于output/目录下,命名格式:`订单均摊结算报表_${账期起始日}_${账期结束日}.xlsx` 2. 报表包含以下Sheet: - 汇总表:订单数、正式订单数、试用订单数、冲销前税费、冲销税费、补充税费、冲销后税费、冲销前均摊金额、冲销均摊金额、补充均摊金额、冲销后均摊金额 - - 订单明细:订单号、订单金额、税率、税额、税后金额、总均摊周期、已均摊天数、历史均摊金额、账期均摊金额、未确认收入、剩余周期 + - 订单明细:订单号、下单日期、退费日期、订单分类、原订单金额、退款金额、退后金额、税率、原订单税额、当前税后金额、当前税额、原始均摊周期、退后均摊周期、历史累计均摊金额、冲销历史累计摊销金额、退后订单历史累计摊销金额、冲销原税款、冲销未确认收入、当期摊销金额、账期税额、未确认收入、剩余周期 3. 文字回复核心指标: > 📊 账期${账期起始日}至${账期结束日}均摊结算结果: > 总订单数:XXX单 diff --git a/skills/vala-order-amortization-stat/run.py b/skills/vala-order-amortization-stat/run.py index 004cff1..ecc403e 100755 --- a/skills/vala-order-amortization-stat/run.py +++ b/skills/vala-order-amortization-stat/run.py @@ -46,6 +46,7 @@ def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) summary_sql_path = os.path.join(script_dir, 'sql', 'summary.sql') detail_sql_path = os.path.join(script_dir, 'sql', 'detail.sql') + prepaid_sql_path = os.path.join(script_dir, 'sql', 'prepaid.sql') if not os.path.exists(summary_sql_path) or not os.path.exists(detail_sql_path): print("错误:SQL模板文件不存在", file=sys.stderr) @@ -55,10 +56,15 @@ def main(): summary_sql_template = f.read() with open(detail_sql_path, 'r', encoding='utf-8') as f: detail_sql_template = f.read() + prepaid_sql_template = None + if os.path.exists(prepaid_sql_path): + with open(prepaid_sql_path, 'r', encoding='utf-8') as f: + prepaid_sql_template = f.read() # Replace placeholders summary_sql = summary_sql_template.format(period_start=start_date, period_end=end_date) detail_sql = detail_sql_template.format(period_start=start_date, period_end=end_date) + prepaid_sql = prepaid_sql_template.format(period_start=start_date, period_end=end_date) if prepaid_sql_template else None # Connect to PostgreSQL try: @@ -77,6 +83,7 @@ def main(): try: summary_df = pd.read_sql(summary_sql, conn) detail_df = pd.read_sql(detail_sql, conn) + prepaid_df = pd.read_sql(prepaid_sql, conn) if prepaid_sql else None except Exception as e: print(f"SQL执行失败:{str(e)}", file=sys.stderr) conn.close() @@ -93,6 +100,8 @@ def main(): with pd.ExcelWriter(output_file, engine='openpyxl') as writer: summary_df.to_excel(writer, sheet_name='汇总表', index=False) detail_df.to_excel(writer, sheet_name='订单明细', index=False) + if prepaid_df is not None: + prepaid_df.to_excel(writer, sheet_name='本月预收账款', index=False) except Exception as e: print(f"Excel生成失败:{str(e)}", file=sys.stderr) sys.exit(1) diff --git a/skills/vala-order-amortization-stat/sql/detail.sql b/skills/vala-order-amortization-stat/sql/detail.sql index d1de976..a3b28cb 100644 --- a/skills/vala-order-amortization-stat/sql/detail.sql +++ b/skills/vala-order-amortization-stat/sql/detail.sql @@ -195,24 +195,71 @@ SELECT WHEN DATE(amortization_start_date) > '{period_end}'::date THEN '试用期中' ELSE '正常订单' END AS "订单分类", - ROUND(pay_amount::numeric, 2) AS "订单金额", + ROUND(pay_amount::numeric, 2) AS "原订单金额", + ROUND(total_refund_amount::numeric, 2) AS "退款金额", + ROUND((pay_amount - total_refund_amount)::numeric, 2) AS "退后金额", ROUND(tax_rate::numeric, 4) AS "税率", ROUND(tax_amount::numeric, 2) AS "原订单税额", - ROUND(after_tax_amount::numeric, 2) AS "原订单税后金额", + ROUND( + CASE + WHEN has_refund = 1 AND is_full_refund = 0 + THEN (pay_amount - total_refund_amount) / (1 + tax_rate) + WHEN has_refund = 1 AND is_full_refund = 1 + THEN 0 + ELSE after_tax_amount + END::numeric, 2 + ) AS "当前税后金额", + ROUND( + CASE + WHEN has_refund = 1 AND is_full_refund = 0 + THEN (pay_amount - total_refund_amount) / (1 + tax_rate) * tax_rate + WHEN has_refund = 1 AND is_full_refund = 1 + THEN 0 + ELSE tax_amount + END::numeric, 2 + ) AS "当前税额", original_cycle_days AS "原始均摊周期", total_cycle_days AS "退后均摊周期", - -- 历史均摊金额(基于原始日均摊) - ROUND((historical_amorted_days * original_daily_amort_amount)::numeric, 2) AS "历史均摊金额", - - -- 冲销历史均摊(场景B/E:历史下单+账期内退费,排除试用期内退费) + -- 历史累计均摊金额(仅历史订单有值,账期内订单=0;账期内退费按退费前状态算) + -- 账期前部分退费:按退后日均摊×退后历史天数 + -- 正常订单/账期内退费:按原始日均摊×历史天数 ROUND( - CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 AND historical_amorted_days > 0 + CASE + WHEN is_ordered_in_period = 1 THEN 0 + WHEN is_pre_period_partial_refund = 1 AND historical_amorted_days > 0 + THEN historical_amorted_days * daily_amort_amount + WHEN historical_amorted_days > 0 + THEN historical_amorted_days * original_daily_amort_amount + ELSE 0 + END::numeric, 2 + ) AS "历史累计均摊金额", + + -- 冲销历史累计摊销金额(仅场景B/E:历史下单+账期内退费,排除试用期内退费) + ROUND( + CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 AND is_trial_period_refund = 0 + AND historical_amorted_days > 0 THEN -(historical_amorted_days * original_daily_amort_amount) ELSE 0 END::numeric, 2 - ) AS "冲销历史均摊", + ) AS "冲销历史累计摊销金额", + + -- 退后订单历史累计摊销金额(站在账期最后一天,看所有订单在账期第一天之前的有效历史摊销金额) + -- 账期内退费的订单按退后状态算;账期前部分退费按退后状态算;正常订单按原始状态算 + ROUND( + CASE + WHEN is_ordered_in_period = 1 THEN 0 + WHEN is_pre_period_partial_refund = 1 AND historical_amorted_days > 0 + THEN historical_amorted_days * daily_amort_amount + WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 + AND is_trial_period_refund = 0 AND historical_amorted_days > 0 + THEN historical_amorted_days * daily_amort_amount + WHEN historical_amorted_days > 0 + THEN historical_amorted_days * original_daily_amort_amount + ELSE 0 + END::numeric, 2 + ) AS "退后订单历史累计摊销金额", -- 冲销原税款(场景B/E:历史下单+账期内退费,排除试用期内退费) ROUND( @@ -232,7 +279,7 @@ SELECT END::numeric, 2 ) AS "冲销未确认收入", - -- 账期均摊金额 + -- 当期摊销金额 ROUND( CASE -- 场景F:账期前全额退费,不统计 @@ -274,7 +321,7 @@ SELECT END ELSE 0 END::numeric, 2 - ) AS "账期均摊金额", + ) AS "当期摊销金额", -- 退后订单税额(仅账期内下单且已转正的非全额退费订单计税) ROUND( diff --git a/skills/vala-order-amortization-stat/sql/prepaid.sql b/skills/vala-order-amortization-stat/sql/prepaid.sql new file mode 100644 index 0000000..553762b --- /dev/null +++ b/skills/vala-order-amortization-stat/sql/prepaid.sql @@ -0,0 +1,148 @@ +-- 本月预收账款(Sheet3) +-- 参数:{period_start} 账期起始日,{period_end} 账期结束日 +-- "本月" = 账期所在月,"上月" = 账期月前一个月 +-- 金额均为含税金额 + +WITH +-- 上月月份范围 +month_range AS ( + SELECT + '{period_start}'::date AS period_start, + '{period_end}'::date AS period_end, + DATE_TRUNC('month', '{period_start}'::date) AS this_month_start, + (DATE_TRUNC('month', '{period_start}'::date) - INTERVAL '1 day')::date AS last_month_end, + (DATE_TRUNC('month', '{period_start}'::date) - INTERVAL '1 month')::date AS last_month_start +), +-- 所有退费记录 +all_refund_records AS ( + SELECT + out_trade_no AS order_no, + SUM(refund_amount_int) / 100 AS total_refund_amount, + MAX(CASE WHEN refund_type = 2 THEN 1 ELSE 0 END) AS is_full_refund, + MAX(DATE(updated_at)) AS latest_refund_date + FROM bi_refund_order + WHERE status = 3 + GROUP BY out_trade_no +), +-- 所有订单基础信息(含退费标记) +order_base AS ( + SELECT + o.out_trade_no AS order_no, + DATE(o.created_at) AS order_date, + o.pay_amount_int / 100 AS pay_amount, + CASE WHEN ar.order_no IS NOT NULL THEN 1 ELSE 0 END AS has_refund, + COALESCE(ar.total_refund_amount, 0) AS total_refund_amount, + CASE WHEN ar.order_no IS NOT NULL AND COALESCE(ar.total_refund_amount, 0) >= o.pay_amount_int / 100 THEN 1 ELSE 0 END AS is_full_refund, + ar.latest_refund_date, + -- 转正日期 = 下单日 + 7天 + DATE(o.created_at) + INTERVAL '7 days' AS amortization_start_date, + -- 有效金额(部分退费 = 原金额 - 退费;未退费 = 原金额;全额退费 = 0) + CASE + WHEN ar.order_no IS NOT NULL AND COALESCE(ar.total_refund_amount, 0) >= o.pay_amount_int / 100 THEN 0 + ELSE o.pay_amount_int / 100 - COALESCE(ar.total_refund_amount, 0) + END AS effective_amount, + o.order_status + FROM bi_vala_order o + JOIN bi_vala_app_account a ON o.account_id = a.id + LEFT JOIN all_refund_records ar ON o.out_trade_no = ar.order_no + CROSS JOIN month_range mr + WHERE + o.created_at >= mr.last_month_start + AND o.created_at <= mr.period_end + AND o.order_status IN (3,4) + AND o.pay_amount_int >= 1000 + AND a.status = 1 +), +-- 标记订单分类 +order_classified AS ( + SELECT + ob.*, + mr.*, + -- 上月下单 + CASE WHEN ob.order_date >= mr.last_month_start AND ob.order_date <= mr.last_month_end THEN 1 ELSE 0 END AS is_last_month_order, + -- 本月下单 + CASE WHEN ob.order_date >= mr.this_month_start AND ob.order_date <= mr.period_end THEN 1 ELSE 0 END AS is_this_month_order, + -- 上月未转正(转正日 > 上月最后一天) + CASE WHEN ob.amortization_start_date > mr.last_month_end THEN 1 ELSE 0 END AS not_formal_in_last_month, + -- 本月已转正(转正日 <= 账期结束日) + CASE WHEN ob.amortization_start_date <= mr.period_end THEN 1 ELSE 0 END AS formal_in_this_month, + -- 上月已全额退费 + CASE WHEN ob.has_refund = 1 AND ob.is_full_refund = 1 AND ob.latest_refund_date <= mr.last_month_end THEN 1 ELSE 0 END AS full_refund_in_last_month, + -- 本月退费金额(退费日期在账期内) + CASE WHEN ob.has_refund = 1 + AND ob.latest_refund_date >= mr.period_start + AND ob.latest_refund_date <= mr.period_end + THEN ob.total_refund_amount + ELSE 0 + END AS refund_in_period + FROM order_base ob + CROSS JOIN month_range mr +) +SELECT + '上月订单转正' AS "分类", + -- 新增:上月下单 + 上月未转正 + 剔除上月全额退费 + 部分退费算有效金额 + ROUND(SUM( + CASE WHEN is_last_month_order = 1 + AND not_formal_in_last_month = 1 + AND full_refund_in_last_month = 0 + THEN effective_amount + ELSE 0 + END + )::numeric, 2) AS "新增", + -- 退费:这些订单在本月的退费金额 + ROUND(SUM( + CASE WHEN is_last_month_order = 1 + AND not_formal_in_last_month = 1 + AND full_refund_in_last_month = 0 + THEN refund_in_period + ELSE 0 + END + )::numeric, 2) AS "退费" + +FROM order_classified + +UNION ALL + +SELECT + '本月订单转正' AS "分类", + -- 新增:本月下单 + 本月已转正 + 不考虑退费(原订单金额) + ROUND(SUM( + CASE WHEN is_this_month_order = 1 + AND formal_in_this_month = 1 + THEN pay_amount + ELSE 0 + END + )::numeric, 2) AS "新增", + -- 退费:这些订单在本月的退费金额 + ROUND(SUM( + CASE WHEN is_this_month_order = 1 + AND formal_in_this_month = 1 + THEN refund_in_period + ELSE 0 + END + )::numeric, 2) AS "退费" + +FROM order_classified + +UNION ALL + +SELECT + '本月订单未转正' AS "分类", + -- 新增:本月下单 + 本月未转正 + 不考虑退费 + ROUND(SUM( + CASE WHEN is_this_month_order = 1 + AND formal_in_this_month = 0 + THEN pay_amount + ELSE 0 + END + )::numeric, 2) AS "新增", + -- 退费:这些订单在本月的退费金额 + ROUND(SUM( + CASE WHEN is_this_month_order = 1 + AND formal_in_this_month = 0 + THEN refund_in_period + ELSE 0 + END + )::numeric, 2) AS "退费" + +FROM order_classified; diff --git a/skills/vala-order-amortization-stat/sql/summary.sql b/skills/vala-order-amortization-stat/sql/summary.sql index 65ad0d8..55b02e1 100644 --- a/skills/vala-order-amortization-stat/sql/summary.sql +++ b/skills/vala-order-amortization-stat/sql/summary.sql @@ -122,105 +122,115 @@ order_with_days AS ( THEN EXTRACT(DAY FROM (LEAST('{period_end}'::date, oc2.amortization_end_date) - oc2.amortization_start_date))::int + 1 ELSE 0 END AS refunded_amort_days_to_period_end FROM order_classified oc2 -), --- 步骤6:汇总 -summary_calc AS ( - SELECT - -- 账期内新增订单统计 - COUNT(CASE WHEN order_date >= '{period_start}'::date AND order_date <= '{period_end}'::date THEN 1 END) AS total_new_orders, - COUNT(CASE WHEN order_date >= '{period_start}'::date AND order_date <= '{period_end}'::date - AND amortization_start_date <= '{period_end}'::date THEN 1 END) AS formal_orders, - COUNT(CASE WHEN order_date >= '{period_start}'::date AND order_date <= '{period_end}'::date - AND amortization_start_date > '{period_end}'::date THEN 1 END) AS trial_orders, - - -- 冲销历史均摊(场景B/E:历史下单+账期内退费,排除试用期内退费) - ROUND(SUM( - CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 AND historical_amorted_days > 0 - AND is_trial_period_refund = 0 - THEN historical_amorted_days * original_daily_amort_amount - ELSE 0 - END - )::numeric, 2) AS writeoff_amort, - - -- 冲销原税款(场景B/E:历史下单+账期内退费,排除试用期内退费) - ROUND(SUM( - CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 - AND is_trial_period_refund = 0 - THEN tax_amount - ELSE 0 - END - )::numeric, 2) AS writeoff_tax, - - -- 冲销未确认收入(场景B/E:历史下单+账期内退费,排除试用期内退费) - ROUND(SUM( - CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 - AND is_trial_period_refund = 0 - THEN after_tax_amount - historical_amorted_days * original_daily_amort_amount - ELSE 0 - END - )::numeric, 2) AS writeoff_unconfirmed, - - -- 账期均摊金额 - ROUND(SUM( - CASE - WHEN is_pre_period_full_refund = 1 THEN 0 - WHEN is_trial_period_refund = 1 AND is_full_refund = 1 THEN 0 -- 试用期内全额退费,无均摊 - WHEN is_trial_period_refund = 1 AND is_full_refund = 0 THEN -- 试用期内部分退费,按退后金额从转正日正常均摊 - CASE WHEN total_cycle_days > 0 AND period_amort_days > 0 - THEN daily_amort_amount * period_amort_days ELSE 0 END - WHEN is_current_period_refund = 1 AND is_full_refund = 1 THEN 0 - -- 场景A:账期内下单+部分退费 - WHEN is_current_period_refund = 1 AND is_full_refund = 0 AND is_ordered_in_period = 1 THEN - CASE WHEN total_cycle_days > 0 AND period_amort_days > 0 - THEN daily_amort_amount * period_amort_days ELSE 0 END - -- 场景B:历史下单+账期内部分退费,从转正日重新均摊 - WHEN is_current_period_refund = 1 AND is_full_refund = 0 AND is_ordered_in_period = 0 THEN - CASE WHEN total_cycle_days > 0 AND refunded_amort_days_to_period_end > 0 - THEN daily_amort_amount * refunded_amort_days_to_period_end ELSE 0 END - -- 场景C:账期前部分退费 - WHEN is_pre_period_partial_refund = 1 THEN - CASE WHEN total_cycle_days > 0 AND period_amort_days > 0 - THEN daily_amort_amount * period_amort_days ELSE 0 END - -- 正常订单 - WHEN period_amort_days > 0 THEN - CASE - WHEN eff_end = amortization_end_date - THEN after_tax_amount - original_daily_amort_amount * (original_cycle_days - 1) + original_daily_amort_amount * (period_amort_days - 1) - ELSE original_daily_amort_amount * period_amort_days - END - ELSE 0 - END - )::numeric, 2) AS period_amort, - - -- 账期税费(仅账期内下单且已转正的非全额退费订单) - ROUND(SUM( - CASE - -- 账期内下单 + 已转正 + 部分退费 → 退后订单税额 - WHEN is_ordered_in_period = 1 - AND amortization_start_date <= '{period_end}'::date - AND is_current_period_refund = 1 AND is_full_refund = 0 - THEN refunded_tax_amount - -- 账期内下单 + 已转正 + 未全额退费(含未退费) → 原订单税额 - WHEN is_ordered_in_period = 1 - AND amortization_start_date <= '{period_end}'::date - AND (has_refund = 0 OR (is_current_period_refund = 0 AND is_full_refund = 0)) - THEN tax_amount - ELSE 0 - END - )::numeric, 2) AS period_tax - - FROM order_with_days - WHERE is_pre_period_full_refund = 0 ) +-- 步骤6:最终汇总(与订单明细Sheet2列对齐,前四列替换为订单统计+税率,其余SUM) SELECT - total_new_orders AS "订单数", - formal_orders AS "正式订单数", - trial_orders AS "试用订单数", - writeoff_amort AS "冲销历史均摊", - writeoff_tax AS "冲销原税款", - writeoff_unconfirmed AS "冲销未确认收入", - period_amort AS "账期均摊金额", - period_tax AS "账期税费", - ROUND((period_amort - writeoff_amort)::numeric, 2) AS "冲销后均摊金额", - ROUND((period_tax - writeoff_tax)::numeric, 2) AS "冲销后税费" -FROM summary_calc; + COUNT(CASE WHEN order_date >= '{period_start}'::date AND order_date <= '{period_end}'::date THEN 1 END) AS "订单数", + COUNT(CASE WHEN order_date >= '{period_start}'::date AND order_date <= '{period_end}'::date + AND amortization_start_date <= '{period_end}'::date THEN 1 END) AS "正式订单数", + COUNT(CASE WHEN order_date >= '{period_start}'::date AND order_date <= '{period_end}'::date + AND amortization_start_date > '{period_end}'::date THEN 1 END) AS "试用订单数", + + -- 冲销历史累计摊销金额(SUM,明细列逻辑) + ROUND(SUM( + CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 + AND is_trial_period_refund = 0 + AND historical_amorted_days > 0 + THEN -(historical_amorted_days * original_daily_amort_amount) + ELSE 0 + END + )::numeric, 2) AS "冲销历史累计摊销金额", + + -- 退后订单历史累计摊销金额(SUM,明细列逻辑) + ROUND(SUM( + CASE + WHEN is_ordered_in_period = 1 THEN 0 + WHEN is_pre_period_partial_refund = 1 AND historical_amorted_days > 0 + THEN historical_amorted_days * daily_amort_amount + WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 + AND is_trial_period_refund = 0 AND historical_amorted_days > 0 + THEN historical_amorted_days * daily_amort_amount + WHEN historical_amorted_days > 0 + THEN historical_amorted_days * original_daily_amort_amount + ELSE 0 + END + )::numeric, 2) AS "退后订单历史累计摊销金额", + + -- 冲销原税款(SUM) + ROUND(SUM( + CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 + AND is_trial_period_refund = 0 + THEN -tax_amount + ELSE 0 + END + )::numeric, 2) AS "冲销原税款", + + -- 冲销未确认收入(SUM) + ROUND(SUM( + CASE WHEN is_current_period_refund = 1 AND is_ordered_in_period = 0 + AND is_trial_period_refund = 0 + THEN -(after_tax_amount - historical_amorted_days * original_daily_amort_amount) + ELSE 0 + END + )::numeric, 2) AS "冲销未确认收入", + + -- 当期摊销金额(SUM) + ROUND(SUM( + CASE + WHEN is_pre_period_full_refund = 1 THEN 0 + WHEN is_trial_period_refund = 1 AND is_full_refund = 1 THEN 0 + WHEN is_trial_period_refund = 1 AND is_full_refund = 0 THEN + CASE WHEN total_cycle_days > 0 AND period_amort_days > 0 + THEN daily_amort_amount * period_amort_days ELSE 0 END + WHEN is_current_period_refund = 1 AND is_full_refund = 1 AND is_ordered_in_period = 1 THEN 0 + WHEN is_current_period_refund = 1 AND is_full_refund = 1 AND is_ordered_in_period = 0 THEN 0 + WHEN is_current_period_refund = 1 AND is_full_refund = 0 AND is_ordered_in_period = 1 THEN + CASE WHEN total_cycle_days > 0 AND period_amort_days > 0 + THEN daily_amort_amount * period_amort_days ELSE 0 END + WHEN is_current_period_refund = 1 AND is_full_refund = 0 AND is_ordered_in_period = 0 THEN + CASE WHEN total_cycle_days > 0 AND refunded_amort_days_to_period_end > 0 + THEN daily_amort_amount * refunded_amort_days_to_period_end ELSE 0 END + WHEN is_pre_period_partial_refund = 1 THEN + CASE WHEN total_cycle_days > 0 AND period_amort_days > 0 + THEN daily_amort_amount * period_amort_days ELSE 0 END + WHEN period_amort_days > 0 THEN + CASE + WHEN eff_end = amortization_end_date + THEN after_tax_amount - original_daily_amort_amount * (original_cycle_days - 1) + original_daily_amort_amount * (period_amort_days - 1) + ELSE original_daily_amort_amount * period_amort_days + END + ELSE 0 + END + )::numeric, 2) AS "当期摊销金额", + + -- 账期税额(SUM) + ROUND(SUM( + CASE + WHEN is_ordered_in_period = 1 + AND DATE(amortization_start_date) <= '{period_end}'::date + AND is_current_period_refund = 1 AND is_full_refund = 0 + THEN refunded_tax_amount + WHEN is_ordered_in_period = 1 + AND DATE(amortization_start_date) <= '{period_end}'::date + AND (has_refund = 0 OR (is_current_period_refund = 0 AND is_full_refund = 0)) + THEN tax_amount + ELSE 0 + END + )::numeric, 2) AS "账期税额", + + -- 未确认收入(SUM) + ROUND(SUM( + CASE + WHEN is_pre_period_full_refund = 1 THEN 0 + WHEN is_current_period_refund = 1 AND is_full_refund = 1 THEN 0 + WHEN is_current_period_refund = 1 AND is_full_refund = 0 THEN + refunded_after_tax_amount - daily_amort_amount * refunded_amort_days_to_period_end + WHEN is_pre_period_partial_refund = 1 THEN + refunded_after_tax_amount - daily_amort_amount * total_amorted_days_to_period_end + ELSE + after_tax_amount - original_daily_amort_amount * total_amorted_days_to_period_end + END + )::numeric, 2) AS "未确认收入" + +FROM order_with_days +WHERE is_pre_period_full_refund = 0;